spine-rigc 2.44.0 → 2.45.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 +1 -1
- package/src/assertions/footprints.ts +2 -1
- package/src/bonedist.ts +3 -2
- package/src/chainfit.ts +7 -6
- package/src/check.ts +2 -1
- package/src/compile.ts +2 -1
- package/src/deformgen.ts +2 -1
- package/src/deformsurvey.ts +2 -1
- package/src/framing.ts +2 -1
- package/src/inspect.ts +4 -3
- package/src/mesh.ts +8 -7
- package/src/meshallocation.ts +3 -2
- package/src/meshcarriers.ts +2 -1
- package/src/meshcompare.ts +3 -2
- package/src/meshquality.ts +70 -7
- package/src/meshreduce.ts +96 -1
- package/src/meshskinning.ts +6 -5
- package/src/numeric.ts +23 -0
- package/src/pose.ts +4 -3
- package/src/slots.ts +8 -7
- package/tools/plate.ts +4 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.45.1",
|
|
4
4
|
"description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -59,6 +59,7 @@ import { FOOTPRINT_SLACK, pageFootprint } from '../atlas.ts';
|
|
|
59
59
|
import type { AtlasRegionEntry, AtlasRegionFacts } from './facts/atlas_regions.ts';
|
|
60
60
|
import type { MeshEntry, MeshFacts } from './facts/mesh_attachments.ts';
|
|
61
61
|
import type { RegionJoinFacts } from './facts/region_joins.ts';
|
|
62
|
+
import { hypot2 } from '../numeric.ts';
|
|
62
63
|
|
|
63
64
|
/** A point in page texels, x right and y down from the page's top-left. */
|
|
64
65
|
export type PagePoint = readonly [number, number];
|
|
@@ -266,7 +267,7 @@ export function piecesOverlap(a: readonly PagePoint[], b: readonly PagePoint[]):
|
|
|
266
267
|
const q = poly[(i + 1) % poly.length];
|
|
267
268
|
const nx = q[1] - p[1];
|
|
268
269
|
const ny = p[0] - q[0];
|
|
269
|
-
const len =
|
|
270
|
+
const len = hypot2(nx, ny);
|
|
270
271
|
if (len === 0) continue;
|
|
271
272
|
let minA = Infinity;
|
|
272
273
|
let maxA = -Infinity;
|
package/src/bonedist.ts
CHANGED
|
@@ -85,6 +85,7 @@ import {
|
|
|
85
85
|
type Posable,
|
|
86
86
|
} from './render.ts';
|
|
87
87
|
import { BONEDIST_SPEC, IDENTITY_CORRESPONDENCE } from './correspondence.ts';
|
|
88
|
+
import { hypot2 } from './numeric.ts';
|
|
88
89
|
|
|
89
90
|
export class BoneDistError extends Error {}
|
|
90
91
|
|
|
@@ -193,7 +194,7 @@ function skeletonSize(posable: Pick<Posable, 'data'>): SkeletonSize {
|
|
|
193
194
|
let size = 0;
|
|
194
195
|
let farthest: string | null = null;
|
|
195
196
|
for (const bone of bones) {
|
|
196
|
-
const distance =
|
|
197
|
+
const distance = hypot2(bone.worldX - root.worldX, bone.worldY - root.worldY);
|
|
197
198
|
if (distance > size) {
|
|
198
199
|
size = distance;
|
|
199
200
|
farthest = bone.name;
|
|
@@ -244,7 +245,7 @@ function deltaOf(
|
|
|
244
245
|
const rx = (reference.worldX - referenceRoot.worldX) / rScale;
|
|
245
246
|
const ry = (reference.worldY - referenceRoot.worldY) / rScale;
|
|
246
247
|
return {
|
|
247
|
-
position:
|
|
248
|
+
position: hypot2(cx - rx, cy - ry),
|
|
248
249
|
rotation: Math.abs(wrapDegrees(candidate.rotationX - reference.rotationX)),
|
|
249
250
|
scale: Math.max(Math.abs(candidate.scaleX - reference.scaleX), Math.abs(candidate.scaleY - reference.scaleY)),
|
|
250
251
|
linear: Math.max(
|
package/src/chainfit.ts
CHANGED
|
@@ -106,6 +106,7 @@ import {
|
|
|
106
106
|
type Samples,
|
|
107
107
|
} from './pose.ts';
|
|
108
108
|
import type { SpineBone, SpineRegionAttachment, SpineSkeletonJson } from './types.ts';
|
|
109
|
+
import { hypot2 } from './numeric.ts';
|
|
109
110
|
|
|
110
111
|
export class ChainFitError extends Error {}
|
|
111
112
|
|
|
@@ -844,7 +845,7 @@ function solveFromDescendants(points: InwardPoint[]): BonePlace | null {
|
|
|
844
845
|
if (!(spread > 1e-12)) return null;
|
|
845
846
|
const p = dotp / spread;
|
|
846
847
|
const q = dotq / spread;
|
|
847
|
-
const unit =
|
|
848
|
+
const unit = hypot2(p, q);
|
|
848
849
|
if (!(unit > 1e-9)) return null;
|
|
849
850
|
return {
|
|
850
851
|
x: mX - p * ma + q * mb,
|
|
@@ -991,7 +992,7 @@ function stampCover(into: Uint8Array, width: number, height: number, part: Plate
|
|
|
991
992
|
const sin = Math.sin(pl.rotDeg * DEG);
|
|
992
993
|
const halfW = part.width / 2;
|
|
993
994
|
const halfH = part.height / 2;
|
|
994
|
-
const reach =
|
|
995
|
+
const reach = hypot2(halfW, halfH) * pl.scale + 1;
|
|
995
996
|
const x0 = Math.max(0, Math.floor(pl.cx - reach));
|
|
996
997
|
const x1 = Math.min(width - 1, Math.ceil(pl.cx + reach));
|
|
997
998
|
const y0 = Math.max(0, Math.floor(pl.cy - reach));
|
|
@@ -1665,7 +1666,7 @@ export function estimateChainFit(options: ChainFitOptions): ChainFitReport {
|
|
|
1665
1666
|
anchoredTo.set(name, name);
|
|
1666
1667
|
carried.set(name, []);
|
|
1667
1668
|
if (chainPlace !== null) {
|
|
1668
|
-
pivotDisagreement.set(name,
|
|
1669
|
+
pivotDisagreement.set(name, hypot2(chainPlace.x - place.x, chainPlace.y - place.y));
|
|
1669
1670
|
}
|
|
1670
1671
|
continue;
|
|
1671
1672
|
}
|
|
@@ -1757,7 +1758,7 @@ export function estimateChainFit(options: ChainFitOptions): ChainFitReport {
|
|
|
1757
1758
|
let lever = 0;
|
|
1758
1759
|
for (let i = 0; i < usable.length; i++) {
|
|
1759
1760
|
for (let j = i + 1; j < usable.length; j++) {
|
|
1760
|
-
lever = Math.max(lever,
|
|
1761
|
+
lever = Math.max(lever, hypot2(usable[i].place.x - usable[j].place.x, usable[i].place.y - usable[j].place.y));
|
|
1761
1762
|
}
|
|
1762
1763
|
}
|
|
1763
1764
|
if (solved === null) {
|
|
@@ -1790,8 +1791,8 @@ export function estimateChainFit(options: ChainFitOptions): ChainFitReport {
|
|
|
1790
1791
|
return {
|
|
1791
1792
|
bone: u.bone,
|
|
1792
1793
|
part: u.part,
|
|
1793
|
-
leverPx: roundTo(
|
|
1794
|
-
offsetPx: roundTo(
|
|
1794
|
+
leverPx: roundTo(hypot2(u.place.x - solved.x, u.place.y - solved.y), 3),
|
|
1795
|
+
offsetPx: roundTo(hypot2(px - u.place.x, py - u.place.y), 4),
|
|
1795
1796
|
carried: u.carried,
|
|
1796
1797
|
};
|
|
1797
1798
|
});
|
package/src/check.ts
CHANGED
|
@@ -98,6 +98,7 @@ import {
|
|
|
98
98
|
type TextureSubstitution,
|
|
99
99
|
type Viewport,
|
|
100
100
|
} from './render_shared.ts';
|
|
101
|
+
import { hypot2 } from './numeric.ts';
|
|
101
102
|
// The one name here that is the runtime's: a candidate's pages, read off the shape spine-core's loader returns.
|
|
102
103
|
import type { Posable } from './render.ts';
|
|
103
104
|
import {
|
|
@@ -3699,7 +3700,7 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
|
|
|
3699
3700
|
lines.push(' `⤷ bounded by` line under a drift is what that figure could have reached: for a');
|
|
3700
3701
|
lines.push(` template match, its whole-pixel winner plus ${SUBPIXEL_CLAMP} px on each axis — so a winner at`);
|
|
3701
3702
|
lines.push(
|
|
3702
|
-
` the offset you drew bounds the whole figure at ${
|
|
3703
|
+
` the offset you drew bounds the whole figure at ${hypot2(SUBPIXEL_CLAMP, SUBPIXEL_CLAMP).toFixed(2)} px ` +
|
|
3703
3704
|
'and says the drift is the',
|
|
3704
3705
|
);
|
|
3705
3706
|
lines.push(' instrument, not the rig. Read every drift against its own line and not against a page.');
|
package/src/compile.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
} from './depth.ts';
|
|
36
36
|
import { CompileError, NotImplementedError } from './errors.ts';
|
|
37
37
|
import { parseJsonWithPosition } from './json-position.ts';
|
|
38
|
+
import { hypot2 } from './numeric.ts';
|
|
38
39
|
// The "did you mean" list on a missing atlas region, from the one implementation
|
|
39
40
|
// of it — the same search serves `refuseUnknownKeys`, and a second copy here with
|
|
40
41
|
// a threshold edited is how such a pair drifts apart.
|
|
@@ -7581,7 +7582,7 @@ function checkAxisSelfConsistency(manifest: FaceManifest): void {
|
|
|
7581
7582
|
}
|
|
7582
7583
|
const ex = Math.cos((deg * Math.PI) / 180);
|
|
7583
7584
|
const ey = Math.sin((deg * Math.PI) / 180);
|
|
7584
|
-
if (
|
|
7585
|
+
if (hypot2(unit[0] - ex, unit[1] - ey) > 1e-3) {
|
|
7585
7586
|
throw new CompileError(
|
|
7586
7587
|
`manifest axis.unit [${unit[0]}, ${unit[1]}] does not match axis.deg ${deg} (expected [${f32(ex)}, ${f32(ey)}])`,
|
|
7587
7588
|
);
|
package/src/deformgen.ts
CHANGED
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
*/
|
|
57
57
|
|
|
58
58
|
import { CompileError } from './errors.ts';
|
|
59
|
+
import { hypot2 } from './numeric.ts';
|
|
59
60
|
|
|
60
61
|
/** Which coordinate a bend or a wave reads, and which one it displaces. */
|
|
61
62
|
export type DeformAxis = 'x' | 'y';
|
|
@@ -569,7 +570,7 @@ export function evaluateDeformTransform(
|
|
|
569
570
|
let maxOffset = 0;
|
|
570
571
|
let maxOffsetVertex = 0;
|
|
571
572
|
for (let v = 0; v < count; v++) {
|
|
572
|
-
const m =
|
|
573
|
+
const m = hypot2(offsets[2 * v], offsets[2 * v + 1]);
|
|
573
574
|
if (m > maxOffset) {
|
|
574
575
|
maxOffset = m;
|
|
575
576
|
maxOffsetVertex = v;
|
package/src/deformsurvey.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { CoreInputError, underSkin, type CompiledDocument } from './core/index.t
|
|
|
26
26
|
import { float32Rows, meshWorld, poseDial as corePoseDial, poseJump, recordIdentity, sliderRecordOf, slotDraw, type CoreSurveyPose } from './core/hooks.ts';
|
|
27
27
|
import { modelStructure, type SurveyAnimation, type SurveyDeformTimeline, type SurveyMesh, type SurveySkin, type SurveySlider, type SurveyStructure } from './deformstructure.ts';
|
|
28
28
|
import { areaBand, DEFORM_AREA_EPSILON, float32AreaNoise, stretchSingularValues, triangleAreas } from './areaband.ts';
|
|
29
|
+
import { hypot2 } from './numeric.ts';
|
|
29
30
|
|
|
30
31
|
// The area band lives in `src/areaband.ts` (moved unchanged, issue #1224) so the
|
|
31
32
|
// geometry entry can read it without reaching the compiler, and
|
|
@@ -1433,7 +1434,7 @@ function measurePosed(
|
|
|
1433
1434
|
const dy = deformed[v * 2 + 1] - plain[v * 2 + 1];
|
|
1434
1435
|
if (dx === 0 && dy === 0) continue;
|
|
1435
1436
|
moved++;
|
|
1436
|
-
const distance =
|
|
1437
|
+
const distance = hypot2(dx, dy);
|
|
1437
1438
|
if (distance > maxDisplacement) {
|
|
1438
1439
|
maxDisplacement = distance;
|
|
1439
1440
|
maxDisplacementVertex = v;
|
package/src/framing.ts
CHANGED
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
type Frame,
|
|
57
57
|
type Viewport,
|
|
58
58
|
} from './render_shared.ts';
|
|
59
|
+
import { hypot2 } from './numeric.ts';
|
|
59
60
|
|
|
60
61
|
/** How far a channel must move for a pixel to count as "not background". */
|
|
61
62
|
export const BACKGROUND_TOLERANCE = 8;
|
|
@@ -511,7 +512,7 @@ function cornerSpread(box: ContentBox, displace: (x: number, y: number) => [numb
|
|
|
511
512
|
[right, bottom],
|
|
512
513
|
]) {
|
|
513
514
|
const [dx, dy] = displace(x, y);
|
|
514
|
-
worst = Math.max(worst,
|
|
515
|
+
worst = Math.max(worst, hypot2(dx, dy));
|
|
515
516
|
}
|
|
516
517
|
return worst;
|
|
517
518
|
}
|
package/src/inspect.ts
CHANGED
|
@@ -70,6 +70,7 @@ import {
|
|
|
70
70
|
type Viewport,
|
|
71
71
|
viewportFor,
|
|
72
72
|
} from './render_shared.ts';
|
|
73
|
+
import { hypot2 } from './numeric.ts';
|
|
73
74
|
|
|
74
75
|
/** The legend's format tag. */
|
|
75
76
|
export const INSPECT_SPEC = 'rigc-inspect/1';
|
|
@@ -1553,9 +1554,9 @@ function drawTile(
|
|
|
1553
1554
|
entry.from = point(a.worldX, a.worldY);
|
|
1554
1555
|
entry.to = point(b.worldX, b.worldY);
|
|
1555
1556
|
// Across the whole tile: extend the line past both ends by the tile's diagonal, in world units.
|
|
1556
|
-
const reach =
|
|
1557
|
-
const ux = (b.worldX - a.worldX) /
|
|
1558
|
-
const uy = (b.worldY - a.worldY) /
|
|
1557
|
+
const reach = hypot2(viewport.maxX - viewport.minX, viewport.maxY - viewport.minY);
|
|
1558
|
+
const ux = (b.worldX - a.worldX) / hypot2(b.worldX - a.worldX, b.worldY - a.worldY);
|
|
1559
|
+
const uy = (b.worldY - a.worldY) / hypot2(b.worldX - a.worldX, b.worldY - a.worldY);
|
|
1559
1560
|
const [p0x, p0y] = toPixel(a.worldX - ux * reach, a.worldY - uy * reach);
|
|
1560
1561
|
const [p1x, p1y] = toPixel(a.worldX + ux * reach, a.worldY + uy * reach);
|
|
1561
1562
|
line(tile, tx(p0x) + shiftX, ty(p0y) + shiftY, tx(p1x) + shiftX, ty(p1y) + shiftY, colour('declared'));
|
package/src/mesh.ts
CHANGED
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
* (assertion A18 recompiles and compares bytes).
|
|
44
44
|
*/
|
|
45
45
|
import type { ModelBinding, ModelVertices } from './model.ts';
|
|
46
|
+
import { hypot2 } from './numeric.ts';
|
|
46
47
|
|
|
47
48
|
// The mesh-quality measurement and its report (issue #1224) live in their own
|
|
48
49
|
// module and reach a dependant through this file, which is what
|
|
@@ -468,7 +469,7 @@ export function ringControlAngles(
|
|
|
468
469
|
const [x, y] = positionOf(name);
|
|
469
470
|
const dx = x - center[0];
|
|
470
471
|
const dy = y - center[1];
|
|
471
|
-
if (
|
|
472
|
+
if (hypot2(dx, dy) < 1e-6) {
|
|
472
473
|
throw new MeshError(`control bone "${name}" sits on the aperture centre, so it has no radial direction`);
|
|
473
474
|
}
|
|
474
475
|
return (Math.atan2(dy, dx) * 180) / Math.PI;
|
|
@@ -755,10 +756,10 @@ export function distanceToSegment(
|
|
|
755
756
|
const dx = b[0] - a[0];
|
|
756
757
|
const dy = b[1] - a[1];
|
|
757
758
|
const len = dx * dx + dy * dy;
|
|
758
|
-
if (len === 0) return
|
|
759
|
+
if (len === 0) return hypot2(p[0] - a[0], p[1] - a[1]);
|
|
759
760
|
let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len;
|
|
760
761
|
t = Math.max(0, Math.min(1, t));
|
|
761
|
-
return
|
|
762
|
+
return hypot2(p[0] - (a[0] + dx * t), p[1] - (a[1] + dy * t));
|
|
762
763
|
}
|
|
763
764
|
|
|
764
765
|
/**
|
|
@@ -809,7 +810,7 @@ export function simplifyClosedPolygon(poly: Array<[number, number]>, tolerance:
|
|
|
809
810
|
let far = 1;
|
|
810
811
|
let farD = -1;
|
|
811
812
|
for (let i = 1; i < n; i++) {
|
|
812
|
-
const d =
|
|
813
|
+
const d = hypot2(poly[i][0] - poly[0][0], poly[i][1] - poly[0][1]);
|
|
813
814
|
if (d > farD) {
|
|
814
815
|
farD = d;
|
|
815
816
|
far = i;
|
|
@@ -852,7 +853,7 @@ export function offsetPolygon(poly: Array<[number, number]>, margin: number): Ar
|
|
|
852
853
|
const n2: [number, number] = [e2[1], -e2[0]];
|
|
853
854
|
let bx = n1[0] + n2[0];
|
|
854
855
|
let by = n1[1] + n2[1];
|
|
855
|
-
let len =
|
|
856
|
+
let len = hypot2(bx, by);
|
|
856
857
|
if (len < 1e-9) {
|
|
857
858
|
// A 180-degree turn: the two edges double back, so there is no bisector.
|
|
858
859
|
// The incoming edge's own normal is the only direction that is outward for
|
|
@@ -870,7 +871,7 @@ export function offsetPolygon(poly: Array<[number, number]>, margin: number): Ar
|
|
|
870
871
|
}
|
|
871
872
|
|
|
872
873
|
function unit(dx: number, dy: number): [number, number] {
|
|
873
|
-
const len =
|
|
874
|
+
const len = hypot2(dx, dy);
|
|
874
875
|
return len < 1e-12 ? [0, 0] : [dx / len, dy / len];
|
|
875
876
|
}
|
|
876
877
|
|
|
@@ -1425,7 +1426,7 @@ export function measureContourFit(
|
|
|
1425
1426
|
const nx = x + dx;
|
|
1426
1427
|
if (nx < 0 || nx >= w) continue;
|
|
1427
1428
|
if (!filled[ny * w + nx]) continue;
|
|
1428
|
-
const d =
|
|
1429
|
+
const d = hypot2(dx, dy);
|
|
1429
1430
|
if (d < best) best = d;
|
|
1430
1431
|
}
|
|
1431
1432
|
}
|
package/src/meshallocation.ts
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
import { distanceToSegment, r6, type AlphaMask } from './mesh.ts';
|
|
37
37
|
import { closedPolygonCentres, inClosedPolygon, regionEdgeBound, type MotionAmplitude, type RefinementRegion } from './meshquality.ts';
|
|
38
|
+
import { hypot2 } from './numeric.ts';
|
|
38
39
|
|
|
39
40
|
type Pt = readonly [number, number];
|
|
40
41
|
type Weights = ReadonlyArray<ReadonlyArray<{ bone: string; weight: number }>>;
|
|
@@ -105,7 +106,7 @@ function uniqueEdges(triangles: readonly number[]): Array<[number, number]> {
|
|
|
105
106
|
return out;
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
const lengthOf = (a: Pt, b: Pt): number =>
|
|
109
|
+
const lengthOf = (a: Pt, b: Pt): number => hypot2(b[0] - a[0], b[1] - a[1]);
|
|
109
110
|
|
|
110
111
|
/**
|
|
111
112
|
* Nearest rank at index round((n − 1) · p) of the values sorted ascending, ties by index — §8's percentile rule —
|
|
@@ -530,7 +531,7 @@ function needField(points: readonly Pt[], triangles: readonly number[], weights:
|
|
|
530
531
|
const [wa, wb, wc] = [wt(i), wt(j), wt(k)];
|
|
531
532
|
const gx = ((wb - wa) * (C[1] - A[1]) - (wc - wa) * (B[1] - A[1])) / det;
|
|
532
533
|
const gy = ((wc - wa) * (B[0] - A[0]) - (wb - wa) * (C[0] - A[0])) / det;
|
|
533
|
-
tv +=
|
|
534
|
+
tv += hypot2(gx, gy);
|
|
534
535
|
}
|
|
535
536
|
tv /= 2;
|
|
536
537
|
if (tv <= 0) continue;
|
package/src/meshcarriers.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { MeshReductionError } from './mesh.ts';
|
|
17
17
|
import { areaBand, triangleAreas } from './areaband.ts';
|
|
18
|
+
import { hypot2 } from './numeric.ts';
|
|
18
19
|
|
|
19
20
|
/** `pointInTriangle`'s epsilon in `src/mesh.ts`, applied to each barycentric coordinate. */
|
|
20
21
|
export const CONTAINS = 1e-9;
|
|
@@ -222,7 +223,7 @@ function slackSupport(hits: readonly Carrier[], uvOf: (v: number) => readonly [n
|
|
|
222
223
|
const h = [0, 1, 2].map((k) => {
|
|
223
224
|
const a = p[(k + 1) % 3];
|
|
224
225
|
const b = p[(k + 2) % 3];
|
|
225
|
-
return twice /
|
|
226
|
+
return twice / hypot2(b[0] - a[0], b[1] - a[1]);
|
|
226
227
|
}) as [number, number, number];
|
|
227
228
|
heights.set(c, h);
|
|
228
229
|
widest = Math.max(widest, ...h);
|
package/src/meshcompare.ts
CHANGED
|
@@ -110,6 +110,7 @@ import { poseRawAnimationEach, poseRawSetup, type RawPose } from './core/raw.ts'
|
|
|
110
110
|
import { sampleTime } from './core/animation.ts';
|
|
111
111
|
import { CORE_DEFAULT_SKIN } from './core/skins.ts';
|
|
112
112
|
import { readVersion } from './package_meta.ts';
|
|
113
|
+
import { hypot2 } from './numeric.ts';
|
|
113
114
|
|
|
114
115
|
// ---------------------------------------------------------------------------
|
|
115
116
|
// the input — §3, with what the contract left undefined defined here
|
|
@@ -664,7 +665,7 @@ function inClosedPolygon(p: readonly [number, number], poly: ReadonlyArray<reado
|
|
|
664
665
|
const dy = b[1] - a[1];
|
|
665
666
|
const len = dx * dx + dy * dy;
|
|
666
667
|
const t = len === 0 ? 0 : Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len));
|
|
667
|
-
if (
|
|
668
|
+
if (hypot2(p[0] - a[0] - dx * t, p[1] - a[1] - dy * t) <= ON_BOUNDARY) return true;
|
|
668
669
|
}
|
|
669
670
|
let inside = false;
|
|
670
671
|
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
|
@@ -1082,7 +1083,7 @@ function compareMeshesInMotionWith(input: MotionComparisonInput, plant: CompareP
|
|
|
1082
1083
|
if (!both[j]) continue;
|
|
1083
1084
|
const p = carried(refCarrier[j]!, rw);
|
|
1084
1085
|
const q = carried(candCarrier[j]!, cw);
|
|
1085
|
-
const d =
|
|
1086
|
+
const d = hypot2(q[0] - p[0], q[1] - p[1]);
|
|
1086
1087
|
if (d > worst) {
|
|
1087
1088
|
worst = d;
|
|
1088
1089
|
worstAt = j;
|
package/src/meshquality.ts
CHANGED
|
@@ -74,6 +74,7 @@ import { allocationContrast, boundaryNecessityOnce, deformLoad, gradeMax, minAng
|
|
|
74
74
|
import { skinningEchoOf, skinningResidual, sourceMeshDigest, validateSkinning, type ReductionSkinning, type SkinningDetail, type SkinningEcho, type SkinningPlant, type SkinningResidualInput } from './meshskinning.ts';
|
|
75
75
|
import { artRastersOf, type ArtRasters, type CoverageReading, type OutlineMemo, type RegionEdgeReading, type SilhouetteReading, type StepRasters } from './meshrasters.ts';
|
|
76
76
|
import { cropToSpineY } from './transform.ts';
|
|
77
|
+
import { hypot2 } from './numeric.ts';
|
|
77
78
|
|
|
78
79
|
// ---------------------------------------------------------------------------
|
|
79
80
|
// §1 — inputs
|
|
@@ -306,6 +307,54 @@ export interface MeshReductionInput {
|
|
|
306
307
|
* a list. `protect.edges` keeps its meaning: a line held whole is listed there.
|
|
307
308
|
*/
|
|
308
309
|
lines?: NamedLine[];
|
|
310
|
+
/**
|
|
311
|
+
* What refused each kept source vertex (issue #1374) — opt-in. Left out = the report is the one it was before the
|
|
312
|
+
* field existed, byte for byte, and no refusal is measured that the run would not measure. Set, `changes` ends in
|
|
313
|
+
* `kept` (`KeptRefusals`): for the last removal pass, the one that took nothing, each kept source vertex's reason
|
|
314
|
+
* counted per reason and split boundary / interior — and with `'by-vertex'` also listed vertex by vertex with the
|
|
315
|
+
* refusal's text. A refusal the deviation floor decided is measured in full to be named, in that pass only, as
|
|
316
|
+
* `blockingConstraint`'s is. No step, order or mesh reads it. Any other value, `null` included, is refused.
|
|
317
|
+
*/
|
|
318
|
+
explainKept?: 'by-row' | 'by-vertex';
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Issue #1374: why the kept source vertices are kept — `ReductionChanges.kept`, written last in `changes` and only when
|
|
323
|
+
* the input set `explainKept`. The reasons are read from the last removal pass, the one in which no step was taken, so
|
|
324
|
+
* every kept vertex not protected was attempted there once as a single removal, on the mesh the result carries (before
|
|
325
|
+
* any `retriangulate` post-pass). A vertex is boundary when it is a source-hull vertex — a kept source vertex is on the
|
|
326
|
+
* outline exactly then — and interior otherwise. Vertices the refinement inserted are never candidates and are not
|
|
327
|
+
* listed.
|
|
328
|
+
*
|
|
329
|
+
* A reason is the refusal's constraint without the object an author named: a row's code (`MQ_BOUNDARY_DEVIATION`,
|
|
330
|
+
* `MQ_SKINNING_RESIDUAL`, …, the region or line it names left in `refusal`), or a structural condition as the refusal
|
|
331
|
+
* opens — `retriangulation`, `outline`, `protect (a)`, `weightJump (b)`, `line`. A vertex no attempt in such a pass
|
|
332
|
+
* reached reads why, never an absence: `protected` (`protect`, or a line's end or crossing), and — when the run has no
|
|
333
|
+
* pass that took nothing — `budget-exhausted`, `replayed-to-accepted-step` or `no-removal-pass` (the refined source
|
|
334
|
+
* did not meet its targets, so no removal was tried), the termination it ended in.
|
|
335
|
+
*/
|
|
336
|
+
export interface KeptRefusals {
|
|
337
|
+
/** The 1-based removal pass the reasons were read from; null when the run had no pass that took nothing. */
|
|
338
|
+
pass: number | null;
|
|
339
|
+
/** One entry per reason that holds a kept source vertex, ascending by reason. `boundary + interior` over the entries is the kept source vertices. */
|
|
340
|
+
rows: KeptRow[];
|
|
341
|
+
/** `explainKept: 'by-vertex'` only: every kept source vertex, ascending by source index. */
|
|
342
|
+
vertices?: KeptVertex[];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Issue #1374: one reason, and the kept source vertices it holds on the outline and inside it. */
|
|
346
|
+
export interface KeptRow {
|
|
347
|
+
reason: string;
|
|
348
|
+
boundary: number;
|
|
349
|
+
interior: number;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Issue #1374: one kept source vertex, its reason, and the refusal's text as `blockingConstraint` writes it — null for a reason no attempt gave. */
|
|
353
|
+
export interface KeptVertex {
|
|
354
|
+
vertex: number;
|
|
355
|
+
boundary: boolean;
|
|
356
|
+
reason: string;
|
|
357
|
+
refusal: string | null;
|
|
309
358
|
}
|
|
310
359
|
|
|
311
360
|
/**
|
|
@@ -687,8 +736,10 @@ export interface ReductionChanges {
|
|
|
687
736
|
* `stopAfterAccepted: k` replays to the operation at `acceptedAt[k - 1]`.
|
|
688
737
|
*/
|
|
689
738
|
acceptedAt: AcceptedOperation[];
|
|
690
|
-
/** Issue #1283: which triangulation the mesh carries — present only when the input set `retriangulate
|
|
739
|
+
/** Issue #1283: which triangulation the mesh carries — present only when the input set `retriangulate`. */
|
|
691
740
|
retriangulation?: Retriangulation;
|
|
741
|
+
/** Issue #1374: what refused each kept source vertex in the last removal pass — present only when the input set `explainKept`, written last. */
|
|
742
|
+
kept?: KeptRefusals;
|
|
692
743
|
}
|
|
693
744
|
|
|
694
745
|
export interface MeshQualityReport {
|
|
@@ -750,6 +801,8 @@ export interface EffectiveSettings {
|
|
|
750
801
|
lines?: NamedLine[];
|
|
751
802
|
/** A measurement's `lineSource`, by id and `sourceMeshDigest`, echoed when the input set it (issue #1326); absent otherwise. */
|
|
752
803
|
lineSource?: { id: string; digest: string };
|
|
804
|
+
/** A reduction's `explainKept`, echoed when the input set it (issue #1374); absent otherwise. */
|
|
805
|
+
explainKept?: 'by-row' | 'by-vertex';
|
|
753
806
|
}
|
|
754
807
|
|
|
755
808
|
export interface MeshCounts {
|
|
@@ -1413,12 +1466,12 @@ function nearestParameter(a: Pt, b: Pt, c: Pt, d: Pt): number {
|
|
|
1413
1466
|
* to the polygon within the tolerance of `transition`.
|
|
1414
1467
|
*/
|
|
1415
1468
|
function runsAlongOuterBoundary(a: Pt, b: Pt, poly: readonly Pt[], transition: number): boolean {
|
|
1416
|
-
const length =
|
|
1469
|
+
const length = hypot2(b[0] - a[0], b[1] - a[1]);
|
|
1417
1470
|
const n = poly.length;
|
|
1418
1471
|
for (let i = 0; i < n; i++) {
|
|
1419
1472
|
const c = poly[i];
|
|
1420
1473
|
const e = poly[(i + 1) % n];
|
|
1421
|
-
const side =
|
|
1474
|
+
const side = hypot2(e[0] - c[0], e[1] - c[1]);
|
|
1422
1475
|
if (side === 0) continue;
|
|
1423
1476
|
const ux = (e[0] - c[0]) / side;
|
|
1424
1477
|
const uy = (e[1] - c[1]) / side;
|
|
@@ -1473,7 +1526,7 @@ export function edgeIsHeldByRegion(a: readonly [number, number], b: readonly [nu
|
|
|
1473
1526
|
if (d < transition - BAND_CONTACT_TOLERANCE) return true;
|
|
1474
1527
|
if (runsAlongOuterBoundary(a, b, poly, transition)) return true;
|
|
1475
1528
|
// Where on the edge each polygon side is touched: one point, or several apart.
|
|
1476
|
-
const length =
|
|
1529
|
+
const length = hypot2(b[0] - a[0], b[1] - a[1]);
|
|
1477
1530
|
let first = Infinity;
|
|
1478
1531
|
let last = -Infinity;
|
|
1479
1532
|
const n = poly.length;
|
|
@@ -2496,7 +2549,7 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
|
|
|
2496
2549
|
for (let i = 0; i < encoded.length; i += 2) {
|
|
2497
2550
|
const a = encoded[i] / 2;
|
|
2498
2551
|
const b = encoded[i + 1] / 2;
|
|
2499
|
-
edges.push({ a: Math.min(a, b), b: Math.max(a, b), length:
|
|
2552
|
+
edges.push({ a: Math.min(a, b), b: Math.max(a, b), length: hypot2(points[b][0] - points[a][0], points[b][1] - points[a][1]) });
|
|
2500
2553
|
}
|
|
2501
2554
|
edges.sort((p, q) => p.a - q.a || p.b - q.b);
|
|
2502
2555
|
|
|
@@ -2636,14 +2689,14 @@ function regionRows(input: MeshMeasureInput, outline: MeshOutline, hullPolygon:
|
|
|
2636
2689
|
};
|
|
2637
2690
|
const distance = (i: number, p: Pt): number => {
|
|
2638
2691
|
const centre = centreOf(i);
|
|
2639
|
-
return
|
|
2692
|
+
return hypot2(p[0] - centre[0], p[1] - centre[1]);
|
|
2640
2693
|
};
|
|
2641
2694
|
if (steps === null) {
|
|
2642
2695
|
for (const i of regionPixels) {
|
|
2643
2696
|
const centre = centreOf(i);
|
|
2644
2697
|
if (!inClosedPolygon(centre, hullPolygon)) continue;
|
|
2645
2698
|
let nearest = Infinity;
|
|
2646
|
-
for (const p of points) nearest = Math.min(nearest,
|
|
2699
|
+
for (const p of points) nearest = Math.min(nearest, hypot2(p[0] - centre[0], p[1] - centre[1]));
|
|
2647
2700
|
visit(i, nearest);
|
|
2648
2701
|
}
|
|
2649
2702
|
} else {
|
|
@@ -2805,6 +2858,7 @@ function effectiveJson(e: EffectiveSettings): Json {
|
|
|
2805
2858
|
...(e.overBound === undefined ? {} : { overBound: { maxSamples: e.overBound.maxSamples, triangles: e.overBound.triangles } }),
|
|
2806
2859
|
...(e.lines === undefined ? {} : { lines: e.lines.map((l): Json => ({ name: l.name, vertices: [...l.vertices], closed: l.closed, maxDeviation: l.maxDeviation })) }),
|
|
2807
2860
|
...(e.lineSource === undefined ? {} : { lineSource: { id: e.lineSource.id, digest: e.lineSource.digest } }),
|
|
2861
|
+
...(e.explainKept === undefined ? {} : { explainKept: e.explainKept }),
|
|
2808
2862
|
};
|
|
2809
2863
|
}
|
|
2810
2864
|
|
|
@@ -2950,6 +3004,15 @@ function candidateJson(c: CandidateReport): Json {
|
|
|
2950
3004
|
monotonicity: k.retriangulation.monotonicity,
|
|
2951
3005
|
},
|
|
2952
3006
|
}),
|
|
3007
|
+
...(k.kept === undefined
|
|
3008
|
+
? {}
|
|
3009
|
+
: {
|
|
3010
|
+
kept: {
|
|
3011
|
+
pass: k.kept.pass,
|
|
3012
|
+
rows: k.kept.rows.map((r) => ({ reason: r.reason, boundary: r.boundary, interior: r.interior })),
|
|
3013
|
+
...(k.kept.vertices === undefined ? {} : { vertices: k.kept.vertices.map((v) => ({ vertex: v.vertex, boundary: v.boundary, reason: v.reason, refusal: v.refusal })) }),
|
|
3014
|
+
},
|
|
3015
|
+
}),
|
|
2953
3016
|
};
|
|
2954
3017
|
}
|
|
2955
3018
|
return out;
|
package/src/meshreduce.ts
CHANGED
|
@@ -90,6 +90,15 @@
|
|
|
90
90
|
* of the triangles it changed; the result's own measurement reads the row in
|
|
91
91
|
* full. Left out, nothing here reads it and the call is the one it was.
|
|
92
92
|
*
|
|
93
|
+
* ## What refused each kept vertex (issue #1374, opt-in)
|
|
94
|
+
*
|
|
95
|
+
* With `explainKept`, the pass that ends the reduction having taken nothing
|
|
96
|
+
* keeps each single removal's refusal and names it once the pass is over — the
|
|
97
|
+
* working mesh is then the one every attempt in it was tried on, so a refusal
|
|
98
|
+
* the deviation floor decided is measured as `blockingConstraint`'s is — and the
|
|
99
|
+
* result's `changes.kept` counts the kept source vertices per reason, boundary
|
|
100
|
+
* and interior (`keptOf`). No step, order or mesh reads it.
|
|
101
|
+
*
|
|
93
102
|
* A removal whose deviation floor — the furthest removed source-hull vertex
|
|
94
103
|
* from the candidate's outline, which `MQ_BOUNDARY_DEVIATION` can only exceed —
|
|
95
104
|
* is over the bound is refused without the measurement; its name, when read,
|
|
@@ -148,6 +157,8 @@ import {
|
|
|
148
157
|
type CandidateReport,
|
|
149
158
|
type DeformKeyInput,
|
|
150
159
|
type EffectiveSettings,
|
|
160
|
+
type KeptRefusals,
|
|
161
|
+
type KeptVertex,
|
|
151
162
|
type MeasureRow,
|
|
152
163
|
type MeshCounts,
|
|
153
164
|
type MeshMeasureInput,
|
|
@@ -344,6 +355,9 @@ function validateReduction(input: MeshReductionInput, plant: ReductionPlant | nu
|
|
|
344
355
|
if ('removalOrder' in input && input.removalOrder !== undefined && input.removalOrder !== 'deformation-load') {
|
|
345
356
|
refuse('REDUCE_INPUT_MISSING', `${who}: removalOrder is ${JSON.stringify(input.removalOrder)}; required "deformation-load" — single removals in ascending predicted deformation load, ties by source index — or the field left out (issue #1283: ascending source index)`);
|
|
346
357
|
}
|
|
358
|
+
if ('explainKept' in input && input.explainKept !== undefined && input.explainKept !== 'by-row' && input.explainKept !== 'by-vertex') {
|
|
359
|
+
refuse('REDUCE_INPUT_MISSING', `${who}: explainKept is ${JSON.stringify(input.explainKept)}; required "by-row" — what refused the kept source vertices in the last removal pass, counted per reason — or "by-vertex" — those counts and every kept source vertex listed — or the field left out (issue #1374: no such block)`);
|
|
360
|
+
}
|
|
347
361
|
// Issue #1287: refused here, before the admission measurement and any step, in the measurement's own words — the
|
|
348
362
|
// admission does not carry the field, and a call whose source is refused never reaches the one measurement that does.
|
|
349
363
|
if (plant !== 'amplitude-unvalidated') validateMotionAmplitude(who, input.motionAmplitude);
|
|
@@ -961,6 +975,18 @@ interface Run {
|
|
|
961
975
|
hooks: SkinningHooks | null;
|
|
962
976
|
/** Issue #1326: the declared lines, or null when the input left `lines` out. */
|
|
963
977
|
lines: LineRun | null;
|
|
978
|
+
/** Issue #1374: the refusals of the current pass's single removals, and the last pass's once it took nothing — null when the input left `explainKept` out. */
|
|
979
|
+
kept: KeptLog | null;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Issue #1374: what `explainKept` reads. `attempts` is the current pass's single removals refused, by source vertex,
|
|
984
|
+
* as tried — a floor's refusal still a thunk, read only if the pass takes nothing; `final` is that pass's, read in
|
|
985
|
+
* the state it was tried in, once a pass ends having taken nothing.
|
|
986
|
+
*/
|
|
987
|
+
interface KeptLog {
|
|
988
|
+
attempts: Map<number, Refusal>;
|
|
989
|
+
final: { pass: number; refusals: Map<number, string> } | null;
|
|
964
990
|
}
|
|
965
991
|
|
|
966
992
|
/** Issue #1326: the input's lines as the steps read them. */
|
|
@@ -1486,7 +1512,14 @@ export type ReductionPlant =
|
|
|
1486
1512
|
| OrderPlant
|
|
1487
1513
|
| AmplitudePlant
|
|
1488
1514
|
| RefinementPlant
|
|
1489
|
-
| LinePlant
|
|
1515
|
+
| LinePlant
|
|
1516
|
+
| KeptPlant;
|
|
1517
|
+
|
|
1518
|
+
/**
|
|
1519
|
+
* Issue #1374's fault, for the `mesh-quality` suite's control: each kept vertex named by the refusal of the next
|
|
1520
|
+
* vertex refused in the last pass (the first's for the last) — a reason that did not refuse it.
|
|
1521
|
+
*/
|
|
1522
|
+
export type KeptPlant = 'kept-reason-shifted';
|
|
1490
1523
|
|
|
1491
1524
|
/**
|
|
1492
1525
|
* Issue #1326's faults in the line mechanism, for the `mesh-quality` suite's controls: the line rows read by no step
|
|
@@ -1909,6 +1942,7 @@ function removeVertices(run: Run): PhaseEnd {
|
|
|
1909
1942
|
let taken = 0;
|
|
1910
1943
|
let tried = 0;
|
|
1911
1944
|
pass++;
|
|
1945
|
+
if (run.kept !== null) run.kept.attempts = new Map();
|
|
1912
1946
|
if (runs !== undefined) {
|
|
1913
1947
|
let walk: number[] | null = null;
|
|
1914
1948
|
for (let s = 0; s < hull; s++) {
|
|
@@ -1990,12 +2024,14 @@ function removeVertices(run: Run): PhaseEnd {
|
|
|
1990
2024
|
if (accept(run, 'removal', [v])) return { kind: 'replayed' };
|
|
1991
2025
|
} else {
|
|
1992
2026
|
lastBlock = { refusal: block, what: `removing source vertex ${v}` };
|
|
2027
|
+
if (run.kept !== null) run.kept.attempts.set(v, block);
|
|
1993
2028
|
if (run.hooks?.plant === 'veto-ends-search' && typeof block === 'string' && block.startsWith('MQ_SKINNING_RESIDUAL')) return { kind: 'stuck', constraint: lastName() };
|
|
1994
2029
|
}
|
|
1995
2030
|
}
|
|
1996
2031
|
if (taken === 0) {
|
|
1997
2032
|
// A pass that took nothing left the working mesh as it found it, so a refusal the floor decided is measured
|
|
1998
2033
|
// now in the state it was decided in.
|
|
2034
|
+
if (run.kept !== null) run.kept.final = { pass, refusals: new Map([...run.kept.attempts].map(([v, r]) => [v, refusalText(r)])) };
|
|
1999
2035
|
const kept = run.lines === null ? '' : ", or an open line's end or a vertex two lines list";
|
|
2000
2036
|
const constraint = tried === 0 ? `protect: every surviving source vertex is protected (protect.hull, protect.vertices, protect.edges, protect.regionBoundaries or a weightJump edge${kept})` : lastName();
|
|
2001
2037
|
return { kind: 'stuck', constraint };
|
|
@@ -2688,6 +2724,7 @@ function reduceValidated(
|
|
|
2688
2724
|
skinning: null,
|
|
2689
2725
|
hooks,
|
|
2690
2726
|
lines: null,
|
|
2727
|
+
kept: input.explainKept === undefined ? null : { attempts: new Map(), final: null },
|
|
2691
2728
|
};
|
|
2692
2729
|
run.skinning = skinningRunOf(run, skinningAdmitted);
|
|
2693
2730
|
run.lines = lineRunOf(run);
|
|
@@ -2880,6 +2917,7 @@ function finish(run: Run, effective: EffectiveSettings, sourceCounts: MeshCounts
|
|
|
2880
2917
|
linkedMeshes: input.linkedMeshes,
|
|
2881
2918
|
acceptedAt: run.acceptedAt.map((a) => ({ step: a.step, kind: a.kind, count: a.count, sourceVertices: [...a.sourceVertices] })),
|
|
2882
2919
|
...(retriangulation === undefined ? {} : { retriangulation }),
|
|
2920
|
+
...(run.kept === null ? {} : { kept: keptOf(run, termination) }),
|
|
2883
2921
|
};
|
|
2884
2922
|
const counts = candidate.counts!;
|
|
2885
2923
|
const mesh: ReducedMesh = {
|
|
@@ -2894,6 +2932,62 @@ function finish(run: Run, effective: EffectiveSettings, sourceCounts: MeshCounts
|
|
|
2894
2932
|
return { mesh, report: reportOf(effective, sourceCounts, { ...candidate, changes }, termination) };
|
|
2895
2933
|
}
|
|
2896
2934
|
|
|
2935
|
+
/**
|
|
2936
|
+
* A refusal's reason (issue #1374, `KeptRefusals`): a row's code, or the structural condition the refusal opens with.
|
|
2937
|
+
* Every refusal a step writes opens with one of them; one that does not is a defect here, thrown rather than named.
|
|
2938
|
+
*/
|
|
2939
|
+
function refusalReason(text: string): string {
|
|
2940
|
+
const code = /^MQ_[A-Z0-9_]+/.exec(text);
|
|
2941
|
+
if (code !== null) return code[0];
|
|
2942
|
+
if (text.startsWith('line "')) return 'line';
|
|
2943
|
+
for (const condition of ['retriangulation', 'outline', 'protect (a)', 'weightJump (b)']) if (text.startsWith(`${condition}:`)) return condition;
|
|
2944
|
+
throw new Error(`issue #1374: a refusal opens with no reason KeptRefusals names: "${text.slice(0, 120)}"`);
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
/**
|
|
2948
|
+
* Issue #1374: `ReductionChanges.kept` — every kept source vertex with its reason: `protected`, else its refusal in the
|
|
2949
|
+
* last pass when one took nothing, else the termination that ended the run without one.
|
|
2950
|
+
*/
|
|
2951
|
+
function keptOf(run: Run, termination: Termination): KeptRefusals {
|
|
2952
|
+
const { work } = run;
|
|
2953
|
+
const hull = run.input.source.hull;
|
|
2954
|
+
const final = run.kept!.final;
|
|
2955
|
+
const unreached = final !== null ? null : termination.reason === 'budget-exhausted' || termination.reason === 'replayed-to-accepted-step' ? termination.reason : 'no-removal-pass';
|
|
2956
|
+
let refusals = final?.refusals ?? null;
|
|
2957
|
+
if (refusals !== null && run.plant === 'kept-reason-shifted') {
|
|
2958
|
+
const order = [...refusals.keys()].sort((a, b) => a - b);
|
|
2959
|
+
refusals = new Map(order.map((v, i) => [v, refusals!.get(order[(i + 1) % order.length])!]));
|
|
2960
|
+
}
|
|
2961
|
+
const vertices: KeptVertex[] = [];
|
|
2962
|
+
for (let v = 0; v < work.nSource; v++) {
|
|
2963
|
+
if (!work.alive[v]) continue;
|
|
2964
|
+
const boundary = v < hull;
|
|
2965
|
+
if (run.protectedVertices.has(v)) {
|
|
2966
|
+
vertices.push({ vertex: v, boundary, reason: 'protected', refusal: null });
|
|
2967
|
+
continue;
|
|
2968
|
+
}
|
|
2969
|
+
if (refusals === null) {
|
|
2970
|
+
vertices.push({ vertex: v, boundary, reason: unreached!, refusal: null });
|
|
2971
|
+
continue;
|
|
2972
|
+
}
|
|
2973
|
+
const refusal = refusals.get(v);
|
|
2974
|
+
if (refusal === undefined) throw new Error(`issue #1374: source vertex ${v} is kept and not protected, yet removal pass ${final!.pass}, which took nothing, did not refuse it`);
|
|
2975
|
+
vertices.push({ vertex: v, boundary, reason: refusalReason(refusal), refusal });
|
|
2976
|
+
}
|
|
2977
|
+
const rows = new Map<string, { boundary: number; interior: number }>();
|
|
2978
|
+
for (const k of vertices) {
|
|
2979
|
+
const row = rows.get(k.reason) ?? { boundary: 0, interior: 0 };
|
|
2980
|
+
if (k.boundary) row.boundary++;
|
|
2981
|
+
else row.interior++;
|
|
2982
|
+
rows.set(k.reason, row);
|
|
2983
|
+
}
|
|
2984
|
+
return {
|
|
2985
|
+
pass: final === null ? null : final.pass,
|
|
2986
|
+
rows: [...rows.keys()].sort().map((reason) => ({ reason, ...rows.get(reason)! })),
|
|
2987
|
+
...(run.input.explainKept === 'by-vertex' ? { vertices } : {}),
|
|
2988
|
+
};
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2897
2991
|
function reportOf(effective: EffectiveSettings, sourceCounts: MeshCounts | null, candidate: CandidateReport, termination: Termination): MeshQualityReport {
|
|
2898
2992
|
return {
|
|
2899
2993
|
spec: 'mesh-quality-report/1',
|
|
@@ -2924,6 +3018,7 @@ function effectiveOf(input: MeshReductionInput, measured: EffectiveSettings, sou
|
|
|
2924
3018
|
...(input.retriangulate === undefined ? {} : { retriangulate: input.retriangulate }),
|
|
2925
3019
|
...(input.removalOrder === undefined ? {} : { removalOrder: input.removalOrder }),
|
|
2926
3020
|
...(input.lines === undefined ? {} : { lines: input.lines }),
|
|
3021
|
+
...(input.explainKept === undefined ? {} : { explainKept: input.explainKept }),
|
|
2927
3022
|
};
|
|
2928
3023
|
}
|
|
2929
3024
|
|
package/src/meshskinning.ts
CHANGED
|
@@ -58,6 +58,7 @@ import { MeshReductionError, r6 } from './mesh.ts';
|
|
|
58
58
|
import { areaBand, triangleAreas } from './areaband.ts';
|
|
59
59
|
import { carrierIn, resolveCarriers, uvCarriers, type Carrier } from './meshcarriers.ts';
|
|
60
60
|
import type { AttachmentRef, DeformTimelineInput, SourceFrame, SourceMesh, WorstSample } from './meshquality.ts';
|
|
61
|
+
import { hypot2 } from './numeric.ts';
|
|
61
62
|
|
|
62
63
|
type Pt = readonly [number, number];
|
|
63
64
|
|
|
@@ -407,8 +408,8 @@ export function sampleResidual(
|
|
|
407
408
|
const dw = Math.abs(c.wbar[k] - r.wbar[k]);
|
|
408
409
|
l1 += dw;
|
|
409
410
|
lInf = Math.max(lInf, dw);
|
|
410
|
-
if (plant !== 'drop-covariance') cov[k] = eps[k] *
|
|
411
|
-
if (plant !== 'drop-lever') lev[k] = dw * (eps[k] *
|
|
411
|
+
if (plant !== 'drop-covariance') cov[k] = eps[k] * hypot2(c.moment[k][0] - r.moment[k][0], c.moment[k][1] - r.moment[k][1]);
|
|
412
|
+
if (plant !== 'drop-lever') lev[k] = dw * (eps[k] * hypot2(r.p[0] - pivot[k][0], r.p[1] - pivot[k][1]) + tau[k]);
|
|
412
413
|
total += cov[k] + lev[k];
|
|
413
414
|
}
|
|
414
415
|
return { total, cov, lev, l1, lInf };
|
|
@@ -539,7 +540,7 @@ export function skinningContract(args: SkinningMeasureArgs, plant: SkinningPlant
|
|
|
539
540
|
for (let v = 0; v < mesh.points.length; v++) {
|
|
540
541
|
const dx = Math.abs(mesh.points[v][0] - mesh.uvs[v * 2] * frame.width);
|
|
541
542
|
const dy = Math.abs(mesh.points[v][1] - mesh.uvs[v * 2 + 1] * frame.height);
|
|
542
|
-
vertexGap = Math.max(vertexGap,
|
|
543
|
+
vertexGap = Math.max(vertexGap, hypot2(dx, dy));
|
|
543
544
|
if ((dx > slack[0] || dy > slack[1]) && plant !== 'setup-unchecked') {
|
|
544
545
|
return out(
|
|
545
546
|
'refused',
|
|
@@ -601,7 +602,7 @@ export function skinningResidual(args: SkinningMeasureArgs, plant: SkinningPlant
|
|
|
601
602
|
measured++;
|
|
602
603
|
const r = termsAt(source, cs, index);
|
|
603
604
|
const c = termsAt(candidate, cc, index);
|
|
604
|
-
sampleGap = Math.max(sampleGap,
|
|
605
|
+
sampleGap = Math.max(sampleGap, hypot2(c.p[0] - r.p[0], c.p[1] - r.p[1]));
|
|
605
606
|
const { total, cov, lev, l1, lInf } = sampleResidual(r, c, eps, tau, pivot, plant);
|
|
606
607
|
l1Max = Math.max(l1Max, l1);
|
|
607
608
|
lInfMax = Math.max(lInfMax, lInf);
|
|
@@ -767,7 +768,7 @@ function envelopeBoneWith(ranges: EnvelopeBoneRanges, plant: EnvelopePlant | nul
|
|
|
767
768
|
for (let j = below.length - 2; j >= 0; j--) {
|
|
768
769
|
if (plant === 'translation-ignored') break;
|
|
769
770
|
const x = below[j];
|
|
770
|
-
delta = eOf(x) *
|
|
771
|
+
delta = eOf(x) * hypot2(c[0] - x.pivot[0], c[1] - x.pivot[1]) + (1 + eOf(x)) * delta + x.translate;
|
|
771
772
|
}
|
|
772
773
|
return { bone: (ranges.chain[ranges.chain.length - 1] as BoneMotionRange).bone, linear: S * composed, pivot: [c[0], c[1]], translation: S * delta };
|
|
773
774
|
}
|
package/src/numeric.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The length of a 2-D vector, spelled so that every platform returns the same bits
|
|
3
|
+
* (issue #1377).
|
|
4
|
+
*
|
|
5
|
+
* `Math.sqrt(dx * dx + dy * dy)` and not `Math.hypot(dx, dy)`. IEEE 754 requires
|
|
6
|
+
* `*`, `+` and `sqrt` to be correctly rounded, so this expression has one answer
|
|
7
|
+
* for one input wherever it runs; `Math.hypot` carries no such requirement, and
|
|
8
|
+
* Bun 1.4.2's was measured returning 34.27192399143071 on macOS and
|
|
9
|
+
* 34.27192399143072 on Linux for the same bit-identical input (rig-c/parts#276).
|
|
10
|
+
* One ulp is enough to turn a strict win into a tie, move a value across a
|
|
11
|
+
* rounding grid, or reorder a sort — each of which this tree writes to disk.
|
|
12
|
+
*
|
|
13
|
+
* What it gives up is `hypot`'s guard against intermediate overflow and
|
|
14
|
+
* underflow: the squares overflow above about 1.3e154 and lose precision below
|
|
15
|
+
* about 1.5e-154. Every length measured here is in pixels, world units or UV
|
|
16
|
+
* units, many orders of magnitude inside both.
|
|
17
|
+
*
|
|
18
|
+
* 🔒 `CUR128` in `selftest.ts` holds `src/` and every module `src/` imports from
|
|
19
|
+
* outside itself free of `Math.hypot`, naming the file and symbol of any call.
|
|
20
|
+
*/
|
|
21
|
+
export function hypot2(dx: number, dy: number): number {
|
|
22
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
23
|
+
}
|
package/src/pose.ts
CHANGED
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
55
55
|
import { basename, join, resolve } from 'node:path';
|
|
56
56
|
import { Plate, readPlate } from '../tools/plate.ts';
|
|
57
|
+
import { hypot2 } from './numeric.ts';
|
|
57
58
|
// The rasteriser's sampler rather than a second one written here: how a pixel is
|
|
58
59
|
// read between two pixel centres is exactly the kind of thing two
|
|
59
60
|
// implementations drift on.
|
|
@@ -1249,7 +1250,7 @@ function localMinima(field: CoarseField, radius: number, keep: number): Candidat
|
|
|
1249
1250
|
const accepted: Candidate[] = [];
|
|
1250
1251
|
for (const cand of found) {
|
|
1251
1252
|
if (accepted.length >= keep) break;
|
|
1252
|
-
if (accepted.some((a) =>
|
|
1253
|
+
if (accepted.some((a) => hypot2(a.cx - cand.cx, a.cy - cand.cy) <= radius * field.stride)) continue;
|
|
1253
1254
|
accepted.push(cand);
|
|
1254
1255
|
}
|
|
1255
1256
|
return accepted;
|
|
@@ -1383,7 +1384,7 @@ function dedupe(sorted: Candidate[], within: number, degrees: number, scaleRatio
|
|
|
1383
1384
|
for (const cand of sorted) {
|
|
1384
1385
|
const same = out.some(
|
|
1385
1386
|
(o) =>
|
|
1386
|
-
|
|
1387
|
+
hypot2(o.cx - cand.cx, o.cy - cand.cy) <= within &&
|
|
1387
1388
|
Math.abs(normaliseDegrees(o.rotDeg - cand.rotDeg)) <= degrees &&
|
|
1388
1389
|
Math.abs(Math.log(o.scale / cand.scale)) <= Math.log(scaleRatio),
|
|
1389
1390
|
);
|
|
@@ -2126,7 +2127,7 @@ function placePart(
|
|
|
2126
2127
|
for (const m of measured) {
|
|
2127
2128
|
const same = distinct.some(
|
|
2128
2129
|
(d) =>
|
|
2129
|
-
|
|
2130
|
+
hypot2(d.placement.x - m.placement.x, d.placement.y - m.placement.y) <= Math.max(1.5, 0.03 * span * m.placement.scale) &&
|
|
2130
2131
|
Math.abs(normaliseDegrees(d.placement.rotationDeg - m.placement.rotationDeg)) <= 5 &&
|
|
2131
2132
|
Math.abs(Math.log(d.placement.scale / m.placement.scale)) <= Math.log(1.05),
|
|
2132
2133
|
);
|
package/src/slots.ts
CHANGED
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
type Footprint,
|
|
56
56
|
type Viewport,
|
|
57
57
|
} from './render_shared.ts';
|
|
58
|
+
import { hypot2 } from './numeric.ts';
|
|
58
59
|
|
|
59
60
|
/** Components smaller than this are antialiasing crumbs, not parts. */
|
|
60
61
|
const MIN_COMPONENT_PIXELS = 4;
|
|
@@ -293,7 +294,7 @@ export interface SlotTrack {
|
|
|
293
294
|
*/
|
|
294
295
|
export function driftBound(track: SlotTrack): number | null {
|
|
295
296
|
if (track.method !== 'template' || track.wholePixel === null) return null;
|
|
296
|
-
return
|
|
297
|
+
return hypot2(Math.abs(track.wholePixel.dx) + SUBPIXEL_CLAMP, Math.abs(track.wholePixel.dy) + SUBPIXEL_CLAMP);
|
|
297
298
|
}
|
|
298
299
|
|
|
299
300
|
/**
|
|
@@ -411,7 +412,7 @@ export function matchSlots(
|
|
|
411
412
|
// a guess, so it is bounded by what this slot could plausibly have moved, and
|
|
412
413
|
// a rival about as near makes it a guess between two things.
|
|
413
414
|
const ranked = components
|
|
414
|
-
.map((c) => ({ c, d:
|
|
415
|
+
.map((c) => ({ c, d: hypot2(c.cx - foot.cx, c.cy - foot.cy) }))
|
|
415
416
|
.sort((a, b) => a.d - b.d);
|
|
416
417
|
const nearest = ranked[0];
|
|
417
418
|
if (nearest.d > radius) {
|
|
@@ -519,7 +520,7 @@ function fillComponentMatch(track: SlotTrack, foot: Footprint, component: Compon
|
|
|
519
520
|
};
|
|
520
521
|
track.driftX = component.cx - foot.cx;
|
|
521
522
|
track.driftY = component.cy - foot.cy;
|
|
522
|
-
track.drift =
|
|
523
|
+
track.drift = hypot2(track.driftX, track.driftY);
|
|
523
524
|
track.widthDrift = foot.maxX - foot.minX - track.reference.width;
|
|
524
525
|
track.heightDrift = foot.maxY - foot.minY - track.reference.height;
|
|
525
526
|
}
|
|
@@ -836,12 +837,12 @@ function applyTemplateMatch(track: SlotTrack, foot: Footprint, source: SlotSourc
|
|
|
836
837
|
const gap = Math.max(RIVAL_GAP, coarse);
|
|
837
838
|
let rival = Infinity;
|
|
838
839
|
for (const { dx, dy, s } of sweep) {
|
|
839
|
-
if (
|
|
840
|
+
if (hypot2(dx - bestX, dy - bestY) < gap) continue;
|
|
840
841
|
if (s < rival) rival = s;
|
|
841
842
|
}
|
|
842
843
|
const confidence = Number.isFinite(rival) && rival > 0 ? Math.max(0, Math.min(1, 1 - best / rival)) : 0;
|
|
843
844
|
|
|
844
|
-
const reach = Math.min(1,
|
|
845
|
+
const reach = Math.min(1, hypot2(bestX, bestY) / radius);
|
|
845
846
|
const required = MIN_CONFIDENCE + reach * CONFIDENCE_SLOPE;
|
|
846
847
|
if (best > template.contrast * MAX_RESIDUAL_FRACTION || confidence < required) {
|
|
847
848
|
track.method = 'none';
|
|
@@ -851,7 +852,7 @@ function applyTemplateMatch(track: SlotTrack, foot: Footprint, source: SlotSourc
|
|
|
851
852
|
`${radius} px either (best residual ${best.toFixed(1)} against its own ${template.contrast.toFixed(1)} of ` +
|
|
852
853
|
`contrast over the ${template.ink - template.covered} of its ${template.ink} px that reach the picture; ` +
|
|
853
854
|
`confidence ${(Number.isFinite(confidence) ? confidence : 0).toFixed(2)} where ` +
|
|
854
|
-
`${required.toFixed(2)} is needed ${
|
|
855
|
+
`${required.toFixed(2)} is needed ${hypot2(bestX, bestY).toFixed(1)} px out)`;
|
|
855
856
|
return;
|
|
856
857
|
}
|
|
857
858
|
|
|
@@ -861,7 +862,7 @@ function applyTemplateMatch(track: SlotTrack, foot: Footprint, source: SlotSourc
|
|
|
861
862
|
track.reference = null;
|
|
862
863
|
track.driftX = dx;
|
|
863
864
|
track.driftY = dy;
|
|
864
|
-
track.drift =
|
|
865
|
+
track.drift = hypot2(dx, dy);
|
|
865
866
|
track.widthDrift = null;
|
|
866
867
|
track.heightDrift = null;
|
|
867
868
|
track.confidence = confidence;
|
package/tools/plate.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { deflateSync, inflateSync } from 'node:zlib';
|
|
|
16
16
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
17
17
|
import { drawText, textWidth } from './font5x7.ts';
|
|
18
18
|
import { assertPng, NotAPngError } from '../src/png.ts';
|
|
19
|
+
import { hypot2 } from '../src/numeric.ts';
|
|
19
20
|
|
|
20
21
|
export type RGBA = [number, number, number, number];
|
|
21
22
|
|
|
@@ -88,7 +89,7 @@ export class Plate {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
line(x0: number, y0: number, x1: number, y1: number, t: number, colour: RGBA): void {
|
|
91
|
-
const steps = Math.ceil(
|
|
92
|
+
const steps = Math.ceil(hypot2(x1 - x0, y1 - y0)) * 2 + 1;
|
|
92
93
|
for (let s = 0; s <= steps; s++) {
|
|
93
94
|
const u = s / steps;
|
|
94
95
|
const cx = x0 + (x1 - x0) * u;
|
|
@@ -100,7 +101,7 @@ export class Plate {
|
|
|
100
101
|
disc(cx: number, cy: number, r: number, colour: RGBA): void {
|
|
101
102
|
for (let y = Math.floor(cy - r); y <= Math.ceil(cy + r); y++) {
|
|
102
103
|
for (let x = Math.floor(cx - r); x <= Math.ceil(cx + r); x++) {
|
|
103
|
-
if (
|
|
104
|
+
if (hypot2(x - cx, y - cy) <= r) this.blend(x, y, colour);
|
|
104
105
|
}
|
|
105
106
|
}
|
|
106
107
|
}
|
|
@@ -108,7 +109,7 @@ export class Plate {
|
|
|
108
109
|
ring(cx: number, cy: number, r: number, t: number, colour: RGBA): void {
|
|
109
110
|
for (let y = Math.floor(cy - r - t); y <= Math.ceil(cy + r + t); y++) {
|
|
110
111
|
for (let x = Math.floor(cx - r - t); x <= Math.ceil(cx + r + t); x++) {
|
|
111
|
-
const d =
|
|
112
|
+
const d = hypot2(x - cx, y - cy);
|
|
112
113
|
if (Math.abs(d - r) <= t) this.blend(x, y, colour);
|
|
113
114
|
}
|
|
114
115
|
}
|