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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "2.44.0",
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 = Math.hypot(nx, ny);
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 = Math.hypot(bone.worldX - root.worldX, bone.worldY - root.worldY);
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: Math.hypot(cx - rx, cy - ry),
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 = Math.hypot(p, q);
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 = Math.hypot(halfW, halfH) * pl.scale + 1;
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, Math.hypot(chainPlace.x - place.x, chainPlace.y - place.y));
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, Math.hypot(usable[i].place.x - usable[j].place.x, usable[i].place.y - usable[j].place.y));
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(Math.hypot(u.place.x - solved.x, u.place.y - solved.y), 3),
1794
- offsetPx: roundTo(Math.hypot(px - u.place.x, py - u.place.y), 4),
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 ${Math.hypot(SUBPIXEL_CLAMP, SUBPIXEL_CLAMP).toFixed(2)} px ` +
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 (Math.hypot(unit[0] - ex, unit[1] - ey) > 1e-3) {
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 = Math.hypot(offsets[2 * v], offsets[2 * v + 1]);
573
+ const m = hypot2(offsets[2 * v], offsets[2 * v + 1]);
573
574
  if (m > maxOffset) {
574
575
  maxOffset = m;
575
576
  maxOffsetVertex = v;
@@ -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 = Math.hypot(dx, dy);
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, Math.hypot(dx, dy));
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 = Math.hypot(viewport.maxX - viewport.minX, viewport.maxY - viewport.minY);
1557
- const ux = (b.worldX - a.worldX) / Math.hypot(b.worldX - a.worldX, b.worldY - a.worldY);
1558
- const uy = (b.worldY - a.worldY) / Math.hypot(b.worldX - a.worldX, 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 (Math.hypot(dx, dy) < 1e-6) {
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 Math.hypot(p[0] - a[0], p[1] - a[1]);
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 Math.hypot(p[0] - (a[0] + dx * t), p[1] - (a[1] + dy * t));
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 = Math.hypot(poly[i][0] - poly[0][0], poly[i][1] - poly[0][1]);
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 = Math.hypot(bx, by);
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 = Math.hypot(dx, dy);
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 = Math.hypot(dx, dy);
1429
+ const d = hypot2(dx, dy);
1429
1430
  if (d < best) best = d;
1430
1431
  }
1431
1432
  }
@@ -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 => Math.hypot(b[0] - a[0], b[1] - a[1]);
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 += Math.hypot(gx, gy);
534
+ tv += hypot2(gx, gy);
534
535
  }
535
536
  tv /= 2;
536
537
  if (tv <= 0) continue;
@@ -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 / Math.hypot(b[0] - a[0], b[1] - a[1]);
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);
@@ -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 (Math.hypot(p[0] - a[0] - dx * t, p[1] - a[1] - dy * t) <= ON_BOUNDARY) return true;
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 = Math.hypot(q[0] - p[0], q[1] - p[1]);
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;
@@ -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`, written last. */
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 = Math.hypot(b[0] - a[0], b[1] - a[1]);
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 = Math.hypot(e[0] - c[0], e[1] - c[1]);
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 = Math.hypot(b[0] - a[0], b[1] - a[1]);
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: Math.hypot(points[b][0] - points[a][0], points[b][1] - points[a][1]) });
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 Math.hypot(p[0] - centre[0], p[1] - centre[1]);
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, Math.hypot(p[0] - centre[0], p[1] - centre[1]));
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
 
@@ -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] * Math.hypot(c.moment[k][0] - r.moment[k][0], c.moment[k][1] - r.moment[k][1]);
411
- if (plant !== 'drop-lever') lev[k] = dw * (eps[k] * Math.hypot(r.p[0] - pivot[k][0], r.p[1] - pivot[k][1]) + tau[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, Math.hypot(dx, dy));
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, Math.hypot(c.p[0] - r.p[0], c.p[1] - r.p[1]));
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) * Math.hypot(c[0] - x.pivot[0], c[1] - x.pivot[1]) + (1 + eOf(x)) * delta + x.translate;
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) => Math.hypot(a.cx - cand.cx, a.cy - cand.cy) <= radius * field.stride)) continue;
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
- Math.hypot(o.cx - cand.cx, o.cy - cand.cy) <= within &&
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
- Math.hypot(d.placement.x - m.placement.x, d.placement.y - m.placement.y) <= Math.max(1.5, 0.03 * span * m.placement.scale) &&
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 Math.hypot(Math.abs(track.wholePixel.dx) + SUBPIXEL_CLAMP, Math.abs(track.wholePixel.dy) + SUBPIXEL_CLAMP);
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: Math.hypot(c.cx - foot.cx, c.cy - foot.cy) }))
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 = Math.hypot(track.driftX, track.driftY);
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 (Math.hypot(dx - bestX, dy - bestY) < gap) continue;
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, Math.hypot(bestX, bestY) / radius);
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 ${Math.hypot(bestX, bestY).toFixed(1)} px out)`;
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 = Math.hypot(dx, dy);
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(Math.hypot(x1 - x0, y1 - y0)) * 2 + 1;
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 (Math.hypot(x - cx, y - cy) <= r) this.blend(x, y, colour);
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 = Math.hypot(x - cx, y - cy);
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
  }