spine-rigc 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/pose.ts CHANGED
@@ -116,15 +116,75 @@ export const COARSE_LONG_SIDE = 40;
116
116
  */
117
117
  export const COARSE_PART_SPAN = 10;
118
118
 
119
+ /**
120
+ * Pixels the part's SHORT side must still span at the coarse level — the same
121
+ * argument as `COARSE_PART_SPAN`, applied to the axis that rule does not read.
122
+ *
123
+ * ⚠️ Found by the change that tightened the coarse grid (issue #865), not by the
124
+ * floor grid, whose parts are none of them thin enough to reach it. MOTION.md
125
+ * §6's 60x14 signal arm is 3.5 px thick at the 4x level its length chose; on the
126
+ * quarter-span grid an anchor cell happened to sit where refinement found its
127
+ * truth (residual 0.0305), and on the eighth-span grid none did, so pose B
128
+ * reported a placement 15 px down the arm at 0.1482. A part three pixels thick
129
+ * at the coarse level has no across-the-part signal for any grid to read. 4, 5
130
+ * and 6 were each measured to move that arm up one level and place it; 4 is the
131
+ * least, and at 4 no trial of the floor grid moves at all.
132
+ */
133
+ const COARSE_PART_THICKNESS = 4;
134
+
119
135
  /**
120
136
  * Coarse anchor positions are stepped at a fraction of the part's own size.
121
137
  *
122
- * The objective varies over distances of order the part, not of order a pixel, so
123
- * scanning every pixel of the coarse level buys resolution the refinement stages
124
- * supply anyway — and it is what makes the scan's cost grow with the frame's area
125
- * instead of with the number of places the part could plausibly be.
138
+ * Scanning every pixel of the coarse level would make the scan's cost grow with
139
+ * the frame's area instead of with the number of places the part could plausibly
140
+ * be, so the anchors are strided. How far apart is the part's own business.
141
+ *
142
+ * ⚠️ An eighth, not the quarter it was, and the reason is measured rather than
143
+ * assumed (issue #865). "The objective varies over distances of order the part"
144
+ * is true of a solid part and false of a thin or holey one: a gun whose material
145
+ * is a third of its box, or a fist with gaps between the fingers, loses most of
146
+ * its fit a quarter-span off its truth. On `tools/pose_floor.ts`'s grid, 65
147
+ * failed trials had a truth that scored better than the answer reported and was
148
+ * missing from the final list; in 38 of them the truth, evaluated exactly at the
149
+ * coarse level, beat every cell of its scale rung, and the anchor CELL holding it
150
+ * did not — the basin fell between two anchors. A cutoff on how many minima
151
+ * survive (3 of the 65) was not what lost them. At an eighth the grid found
152
+ * 226 of 420 trials against 185 (with `MINIMA_PER_SCALE` still at three), and
153
+ * the search misses fell from 65 to 15, for 10–14% more CPU over the grid.
154
+ *
155
+ * 🔸 Not free of losses, and they are stated rather than averaged away: 9 trials
156
+ * that placed on the quarter grid did not on this one when it shipped. Five are
157
+ * blurred parts at 24–96 px that came back ambiguous between two scales at the
158
+ * same spot, or 2.0 px off against a 2 px bar — the objective's, not this
159
+ * grid's. The other four were the refinement's, and since issue #877 all four
160
+ * place at their truth again: the native 48 px arm (walk) at 0.07 px, 48 px
161
+ * goggles (walk) 0.04, 96 px goggles (setup) 0.03 — three polishes stopped in a
162
+ * scale–position valley, now left by `POLISH_SCALE_ESCAPE` — and the 24 px shin
163
+ * (setup) 0.03, ranked 15th into full resolution, now carried by
164
+ * `REFINE_CANDIDATES`. Of the five blurred ones, the blur2 32 px shin (setup)
165
+ * and the blur1 24 px mouth are found again too.
166
+ *
167
+ * ⚠️ #877 lost two of its own, both inside the ambiguity margin rather than
168
+ * off the truth: the blur2 24 px front shin (walk) now reports the truth as its
169
+ * BEST (0.03 px, 0.00989) with the old 0.57 px answer 0.0094 above it, and the
170
+ * blur1 64 px mouth (walk) keeps its best at 0.93 px and gains a second scale
171
+ * at the same spot 0.0031 above it. Each is two readings of one place the
172
+ * 0.01 absolute margin will not pick between — the verdict it exists to give.
126
173
  */
127
- export const COARSE_STRIDE_FRACTION = 0.25;
174
+ export const COARSE_STRIDE_FRACTION = 0.125;
175
+
176
+ /**
177
+ * Grid cells around a coarse cell that must not beat it for it to be a minimum,
178
+ * and the spacing kept between the minima one scale rung sends down.
179
+ *
180
+ * Four at an eighth-span stride is, nominally, the half-span the old two cells
181
+ * at a quarter held (the stride is rounded to whole level pixels, so only
182
+ * nominally). Measured on the grid: leaving it at two when the stride halved let one
183
+ * wrong hill fill a rung's `MINIMA_PER_SCALE` with its own neighbours — 223
184
+ * trials found rather than 226, and 12 that placed on the old grid lost rather
185
+ * than 9 (both at three minima per rung).
186
+ */
187
+ const COARSE_SUPPRESSION_CELLS = 4;
128
188
 
129
189
  /** How many scale rungs one octave gets in the coarse ladder. */
130
190
  export const SCALE_STEPS_PER_OCTAVE = 3;
@@ -180,11 +240,74 @@ export const BACKGROUND_TOLERANCE = 10;
180
240
  /** Share of the frame's border ring one colour must hold before it is called the background. */
181
241
  export const BACKGROUND_BORDER_SHARE = 0.6;
182
242
 
183
- /** How many distinct places each coarse scale rung sends down for refinement. */
184
- const MINIMA_PER_SCALE = 3;
243
+ /**
244
+ * How many distinct places each coarse scale rung sends down for refinement.
245
+ *
246
+ * ⚠️ Five rather than three, and read off the grid rather than off a probe
247
+ * (issue #865). The card's own probe raised this to ten and recovered the fist it
248
+ * was chasing; on the full grid that bought 2 trials net (3 gained, 1 lost),
249
+ * because only 3 of the 65 trials the search missed had their truth's basin
250
+ * ranked under the cutoff — the rest had fallen between anchor cells, which is
251
+ * `COARSE_STRIDE_FRACTION`'s job. With the stride fixed, one miss of that kind was
252
+ * left (a 48 px fist, its basin fifth on its rung), and five is the count that
253
+ * carries it: 227 trials found against 226 at three, none lost, and a CPU cost
254
+ * inside the grid's run-to-run noise.
255
+ */
256
+ const MINIMA_PER_SCALE = 5;
257
+
258
+ /**
259
+ * How many candidates survive each refinement level.
260
+ *
261
+ * ⚠️ Fifteen rather than twelve, and it is the smallest count the grid named
262
+ * (issue #877). Of the 14 trials the search missed after #865, one was lost by
263
+ * this cutoff rather than by any polish: the native 24 px front shin, whose
264
+ * truth's candidate ranked 15th (index 14) of 28 at the 2x level — that part's
265
+ * coarse level — and was cut on the way into full resolution, where the truth
266
+ * scores 0.0199 against the 0.1188 reported. Fifteen is the least count that
267
+ * carries index 14; sixteen alone was measured to place it at 0.03 px and move
268
+ * no other trial (228 found, none lost). A band of the level's best was the
269
+ * alternative, and it was rejected on its number: to carry that candidate it
270
+ * would have had to reach 1.73x the best, and a band that wide carried up to 27
271
+ * candidates (14 of 108 sampled refinement levels over twelve) — a cost set by
272
+ * one trial and paid on all of them.
273
+ */
274
+ const REFINE_CANDIDATES = 15;
185
275
 
186
- /** How many candidates survive each refinement level. */
187
- const REFINE_CANDIDATES = 12;
276
+ /**
277
+ * The factor a converged full-resolution polish tries its scale up by before it
278
+ * stops — half a coarse scale rung, the resolution the coarse ladder itself had.
279
+ *
280
+ * 🔍 What the pattern search cannot do, and the trace table of issue #877 is
281
+ * how it was seen: every probe moves ONE degree of freedom, and the objective
282
+ * couples scale to position along a diagonal valley — a part shrunk a little
283
+ * fits best a pixel or two off its truth, so from there every single-axis probe
284
+ * is worse and every joint move is better. In 12 of the 14 missed trials the
285
+ * truth's candidate reached full resolution ranked first or second, the
286
+ * full-resolution objective preferred the truth to everything kept, and the
287
+ * polish stopped 2–7 px off it at a scale of 0.64–0.96. Not one polish ever
288
+ * accepted a worse residual — a polish moves only on a strictly lower probe of
289
+ * its own level's objective, and `PO26` holds that — so the rise from one level
290
+ * to the next is a change of objective, never a step taken uphill.
291
+ *
292
+ * ⭐ So the escape re-fits position at the new scale before comparing — the
293
+ * joint move, taken one axis at a time — and a better point restarts the polish
294
+ * there. UP only, and that is derived rather than tuned: the objective charges a
295
+ * part pixel that lands off the figure and charges nothing for figure left
296
+ * uncovered, so a shrunk placement is the cheap error the reduced levels make —
297
+ * every stuck point in the table sat below scale 1, none above.
298
+ *
299
+ * 📏 Measured on the grid with `REFINE_CANDIDATES` at fifteen: 227 → 237 trials
300
+ * found, 12 gained, 2 lost, search misses 14 → 8, for 43% more refinement
301
+ * samples (the coarse pass is untouched). Both ways at twelve found 236 (12
302
+ * gained, 3 lost) for 38% more against up only's 21%; a second step each way,
303
+ * 237 (15, 5) for 76%. ⚠️ The two it loses are one shape, a scale twin at the
304
+ * same spot: the blurred 64 px mouth (walk) keeps its best at 0.9 px and now
305
+ * reports a second placement 1.8 px off at scale 0.825 within the ambiguity
306
+ * margin, and the blurred 24 px front shin (walk) now places AT its truth
307
+ * (0.03 px, 0.00989) where it placed 0.57 px off at 0.0191 — the old answer is
308
+ * still reported beside it, 0.0094 above, inside the 0.01 absolute margin.
309
+ */
310
+ const POLISH_SCALE_ESCAPE = 2 ** (1 / (2 * SCALE_STEPS_PER_OCTAVE));
188
311
 
189
312
  /** Sample budgets per stage. The reported residual uses every pixel regardless. */
190
313
  const COARSE_SAMPLES = 96;
@@ -224,8 +347,8 @@ export const POSE_FLOOR = {
224
347
  span: 24,
225
348
  detail: 0.5,
226
349
  /** Found share of the grid's trials under `detail`, and over it — both measured by `deriveFloor`. */
227
- rateBelow: 0.066,
228
- rateAbove: 0.594,
350
+ rateBelow: 0.074,
351
+ rateAbove: 0.765,
229
352
  };
230
353
 
231
354
  /** Which side of `POSE_FLOOR` a part is on, from its own longest side in part pixels and its detail. */
@@ -266,8 +389,16 @@ export function legibilityReading(l: Omit<PoseLegibility, 'reading'>, verdict: '
266
389
  // ⚠️ "Above the floor" is not "the search found the right hill". Measured on
267
390
  // the floor's own material, a part above it came back ambiguous between two
268
391
  // equally WRONG placements (residual 0.148 each, 173 px off) while the truth
269
- // scored 0.035 — a basin the coarse pass never sent down. So the sentence
270
- // names that reading too, with the one remedy that separates it: a window.
392
+ // scored 0.035 — a basin the coarse pass never sent down. Issue #865 fixed
393
+ // that one (the coarse grid had stepped over it), and the reading still
394
+ // occurs: on the grid after it, 10 ambiguous trials above the floor had a
395
+ // truth scoring better than both answers — native 24 px gun and shin, 48 px
396
+ // arm, 96 px goggles twice, and blurred gun, goggles and rear shin from 48 px
397
+ // up. Nine of the ten sat 2–6 px from the truth rather than on another hill.
398
+ // Issue #877's polish took that to 6 — native 24 px gun, 96 px goggles, and
399
+ // blurred goggles and gun from 128 px and rear shin at 48 px — five of them
400
+ // 2.1–2.7 px off, the rear shin 19 px. So the sentence names that reading
401
+ // too, with the one remedy that separates it: a window.
271
402
  return (
272
403
  `${figures} — above the measured floor (AUTHORING §11.5: ${floorClause()}), so size and texture do not explain this; ` +
273
404
  (verdict === 'refused'
@@ -502,6 +633,67 @@ export interface PoseOptions {
502
633
  scale?: { min: number; max: number };
503
634
  rotation?: { minDeg: number; maxDeg: number };
504
635
  maxResidual?: number;
636
+ /**
637
+ * An instrument's sink, never read by the search itself: when present, every
638
+ * level of every part's refinement is appended to it. See `PoseTrace`.
639
+ */
640
+ trace?: PoseTrace;
641
+ }
642
+
643
+ /** A search candidate as the trace states it: where the part image's centre would land, frame pixels. */
644
+ export interface PoseTraceCandidate {
645
+ x: number;
646
+ y: number;
647
+ rotationDeg: number;
648
+ scale: number;
649
+ /** The level's own sampled objective — not the reported, every-pixel residual. */
650
+ residual: number;
651
+ }
652
+
653
+ /** One accepted move of a polish: the residual it moved to and the steps it moved with. */
654
+ export interface PoseTraceStep {
655
+ residual: number;
656
+ translate: number;
657
+ rotate: number;
658
+ scale: number;
659
+ /** The accepted probe's scale or rotation was held by its window rather than taken as stepped. */
660
+ clamped: boolean;
661
+ /** A restart from a converged point to a different scale, rather than a step of the pattern. */
662
+ escape: boolean;
663
+ }
664
+
665
+ export interface PoseTraceLevel {
666
+ part: string;
667
+ /** Pyramid index; 0 is full resolution. */
668
+ level: number;
669
+ reduction: number;
670
+ /** How many of the candidates handed down this level took, before any rotation branching. */
671
+ keep: number;
672
+ /** The seeds this level polished, in the order they were ranked going in. */
673
+ seeds: PoseTraceCandidate[];
674
+ /** Per seed, this level's objective at the seed — where its polish started from. */
675
+ starts: number[];
676
+ /** Per seed, the moves its polish accepted, in order. */
677
+ paths: PoseTraceStep[][];
678
+ /** Per seed, where its polish ended. */
679
+ polished: PoseTraceCandidate[];
680
+ /** The candidates this level hands on, ranked and deduplicated. */
681
+ out: PoseTraceCandidate[];
682
+ /** This level's objective at `PoseTrace.probe`, when one was given. */
683
+ probeResidual: number | null;
684
+ }
685
+
686
+ /**
687
+ * The refinement written down level by level, for an instrument that knows
688
+ * where the truth is and needs to say where the search left it (issue #877).
689
+ * Pure bookkeeping: a search with a trace returns the same report as one without.
690
+ */
691
+ export interface PoseTrace {
692
+ /** A placement, in the report's own space, to score at every level alongside the candidates. */
693
+ probe?: { x: number; y: number; rotationDeg: number; scale: number };
694
+ levels: PoseTraceLevel[];
695
+ /** Per part, every candidate that reached the report, with its every-pixel residual, best first. */
696
+ measured: { part: string; candidates: PoseTraceCandidate[] }[];
505
697
  }
506
698
 
507
699
  // ---------------------------------------------------------------------------
@@ -1030,16 +1222,47 @@ function polish(
1030
1222
  * default run moves.
1031
1223
  */
1032
1224
  rotationBounds: { min: number; max: number; wraps: boolean },
1225
+ /** Scale factors tried from the point the steps converged on; a better one restarts the polish there. */
1226
+ escapes: readonly number[],
1227
+ /** An instrument's record of where the polish started and the moves it accepted; the search never reads it. */
1228
+ path?: { start: number; steps: PoseTraceStep[] },
1033
1229
  ): Candidate {
1034
1230
  const clamp = (v: number): number => Math.min(bounds.max, Math.max(bounds.min, v));
1035
1231
  const hold = (v: number): number =>
1036
1232
  rotationBounds.wraps ? v : Math.min(rotationBounds.max, Math.max(rotationBounds.min, v));
1037
1233
  let cur: Candidate = { ...start, residual: residualAt(level, plate, s, start, smooth) };
1234
+ if (path !== undefined) path.start = cur.residual;
1038
1235
  let dt = step.translate;
1039
1236
  let dr = step.rotate;
1040
1237
  let ds = step.scale;
1041
1238
  for (let guard = 0; guard < 200; guard++) {
1042
- if (dt <= floor.translate && dr <= floor.rotate && ds <= floor.scale) break;
1239
+ if (dt <= floor.translate && dr <= floor.rotate && ds <= floor.scale) {
1240
+ let jump = cur;
1241
+ for (const f of escapes) {
1242
+ const scale = clamp(cur.scale * f);
1243
+ if (scale === cur.scale) continue;
1244
+ const refit = polish(
1245
+ level,
1246
+ plate,
1247
+ s,
1248
+ { ...cur, scale },
1249
+ { translate: step.translate, rotate: 0, scale: 0 },
1250
+ { translate: floor.translate, rotate: 0, scale: 0 },
1251
+ smooth,
1252
+ bounds,
1253
+ rotationBounds,
1254
+ [],
1255
+ );
1256
+ if (refit.residual < jump.residual) jump = refit;
1257
+ }
1258
+ if (jump === cur) break;
1259
+ path?.steps.push({ residual: jump.residual, translate: dt, rotate: dr, scale: jump.scale / cur.scale - 1, clamped: false, escape: true });
1260
+ cur = jump;
1261
+ dt = step.translate;
1262
+ dr = step.rotate;
1263
+ ds = step.scale;
1264
+ continue;
1265
+ }
1043
1266
  const probes: Candidate[] = [];
1044
1267
  const push = (cand: Omit<Candidate, 'residual'>): void => {
1045
1268
  probes.push({ ...cand, residual: residualAt(level, plate, s, { ...cand, residual: 0 }, smooth) });
@@ -1074,6 +1297,12 @@ function polish(
1074
1297
  ds /= 2;
1075
1298
  continue;
1076
1299
  }
1300
+ if (path !== undefined) {
1301
+ const clamped =
1302
+ (best.scale !== cur.scale && best.scale !== cur.scale * (1 + ds) && best.scale !== cur.scale * (1 - ds)) ||
1303
+ (best.rotDeg !== cur.rotDeg && best.rotDeg !== cur.rotDeg + dr && best.rotDeg !== cur.rotDeg - dr);
1304
+ path.steps.push({ residual: best.residual, translate: dt, rotate: dr, scale: ds, clamped, escape: false });
1305
+ }
1077
1306
  cur = best;
1078
1307
  }
1079
1308
  return cur;
@@ -1488,6 +1717,7 @@ export function estimatePose(options: PoseOptions): PoseReport {
1488
1717
  maxResidual,
1489
1718
  { min: scaleMin, max: scaleMax },
1490
1719
  rotationBounds,
1720
+ options.trace,
1491
1721
  ),
1492
1722
  );
1493
1723
  }
@@ -1504,6 +1734,7 @@ function placePart(
1504
1734
  maxResidual: number,
1505
1735
  scaleBounds: { min: number; max: number },
1506
1736
  rotationBounds: { min: number; max: number; wraps: boolean },
1737
+ trace?: PoseTrace,
1507
1738
  ): PosePart {
1508
1739
  const scaleMin = scaleBounds.min;
1509
1740
  /** The scale the sample sets are sized for — the middle of the window, and NOT the scale under test. */
@@ -1623,9 +1854,18 @@ function placePart(
1623
1854
  // only about the frame. The pyramid stops when the frame fits in
1624
1855
  // COARSE_LONG_SIDE; this then walks back UP it until the part still spans
1625
1856
  // COARSE_PART_SPAN pixels there, because a level that has reduced the part to
1626
- // three pixels cannot say where the part is at any price.
1857
+ // three pixels cannot say where the part is at any price — and until its short
1858
+ // side still spans COARSE_PART_THICKNESS, which is the same sentence about a
1859
+ // part that is long and thin.
1627
1860
  let coarseIndex = levels.length - 1;
1628
- while (coarseIndex > 0 && (span * scaleReference) / levels[coarseIndex].reduction < COARSE_PART_SPAN) coarseIndex--;
1861
+ const thickness = Math.min(tw, th);
1862
+ while (
1863
+ coarseIndex > 0 &&
1864
+ ((span * scaleReference) / levels[coarseIndex].reduction < COARSE_PART_SPAN ||
1865
+ (thickness * scaleReference) / levels[coarseIndex].reduction < COARSE_PART_THICKNESS)
1866
+ ) {
1867
+ coarseIndex--;
1868
+ }
1629
1869
  const coarse = levels[coarseIndex];
1630
1870
  const spanAtCoarse = (span * scaleReference) / coarse.reduction;
1631
1871
  const stride = Math.max(1, Math.round(spanAtCoarse * COARSE_STRIDE_FRACTION));
@@ -1635,7 +1875,7 @@ function placePart(
1635
1875
  for (const scale of scales) {
1636
1876
  const field = coarseScan(coarse, coarseSamples, scale, searchRotations, stride);
1637
1877
  grid = { reduction: coarse.reduction, cols: field.cols, rows: field.rows, stride };
1638
- candidates.push(...localMinima(field, 2, MINIMA_PER_SCALE));
1878
+ candidates.push(...localMinima(field, COARSE_SUPPRESSION_CELLS, MINIMA_PER_SCALE));
1639
1879
  }
1640
1880
  base.coarse = grid;
1641
1881
  candidates.sort((a, b) => a.residual - b.residual);
@@ -1692,7 +1932,11 @@ function placePart(
1692
1932
  }
1693
1933
  seeds.push(start);
1694
1934
  }
1695
- candidates = seeds.map((seed) => polish(level, plate, s, seed, step, floor, smooth, scaleBounds, rotationBounds));
1935
+ const paths = seeds.map((): { start: number; steps: PoseTraceStep[] } => ({ start: 0, steps: [] }));
1936
+ candidates = seeds.map((seed, i) =>
1937
+ polish(level, plate, s, seed, step, floor, smooth, scaleBounds, rotationBounds, li === 0 ? [POLISH_SCALE_ESCAPE] : [], trace === undefined ? undefined : paths[i]),
1938
+ );
1939
+ const polished = candidates.slice();
1696
1940
  candidates.sort((a, b) => a.residual - b.residual);
1697
1941
  // ⚠️ Eight branches that walked to one optimum are one candidate, not eight —
1698
1942
  // and the radius has to scale with the PART rather than be a pixel count.
@@ -1701,6 +1945,47 @@ function placePart(
1701
1945
  // instrument exists to keep: with a fixed one-pixel radius the fixture lost
1702
1946
  // one of its two identical arms.
1703
1947
  candidates = dedupe(candidates, Math.max(1, (0.2 * span * scaleReference) / level.reduction), 5, 1.03);
1948
+ if (trace !== undefined) {
1949
+ const ou = part.width / 2 - anchorX;
1950
+ const ov = part.height / 2 - anchorY;
1951
+ const said = (c: Candidate): PoseTraceCandidate => {
1952
+ const cos = Math.cos(c.rotDeg * DEG) * c.scale;
1953
+ const sin = Math.sin(c.rotDeg * DEG) * c.scale;
1954
+ return {
1955
+ x: c.cx * level.reduction + ou * cos - ov * sin,
1956
+ y: c.cy * level.reduction + ou * sin + ov * cos,
1957
+ rotationDeg: normaliseDegrees(c.rotDeg),
1958
+ scale: c.scale,
1959
+ residual: c.residual,
1960
+ };
1961
+ };
1962
+ let probeResidual: number | null = null;
1963
+ if (trace.probe !== undefined) {
1964
+ const q = trace.probe;
1965
+ const cos = Math.cos(q.rotationDeg * DEG) * q.scale;
1966
+ const sin = Math.sin(q.rotationDeg * DEG) * q.scale;
1967
+ const at: Candidate = {
1968
+ cx: (q.x - (ou * cos - ov * sin)) / level.reduction,
1969
+ cy: (q.y - (ou * sin + ov * cos)) / level.reduction,
1970
+ rotDeg: q.rotationDeg,
1971
+ scale: q.scale,
1972
+ residual: 0,
1973
+ };
1974
+ probeResidual = residualAt(level, plate, s, at, smooth);
1975
+ }
1976
+ trace.levels.push({
1977
+ part: name,
1978
+ level: li,
1979
+ reduction: level.reduction,
1980
+ keep,
1981
+ seeds: seeds.map(said),
1982
+ starts: paths.map((p) => p.start),
1983
+ paths: paths.map((p) => p.steps),
1984
+ polished: polished.map(said),
1985
+ out: candidates.map(said),
1986
+ probeResidual,
1987
+ });
1988
+ }
1704
1989
  if (li > 0) {
1705
1990
  candidates = candidates.map((c) => ({ ...c, cx: c.cx * 2, cy: c.cy * 2 }));
1706
1991
  // A rotation-free part keeps its step at zero all the way down, so the
@@ -1738,6 +2023,7 @@ function placePart(
1738
2023
  true,
1739
2024
  scaleBounds,
1740
2025
  rotationBounds,
2026
+ [],
1741
2027
  ),
1742
2028
  );
1743
2029
  }
@@ -1748,6 +2034,16 @@ function placePart(
1748
2034
  // candidates that walked to the same optimum are one answer, not two.
1749
2035
  const measured = candidates.map((cand) => ({ cand, placement: toPlacement(part, anchorX, anchorY, cand, measure(levels[0], plates[0], part, anchorX, anchorY, cand)) }));
1750
2036
  measured.sort((a, b) => a.placement.residual - b.placement.residual || b.placement.footprint - a.placement.footprint);
2037
+ trace?.measured.push({
2038
+ part: name,
2039
+ candidates: measured.map((m) => ({
2040
+ x: m.placement.x,
2041
+ y: m.placement.y,
2042
+ rotationDeg: m.placement.rotationDeg,
2043
+ scale: m.placement.scale,
2044
+ residual: m.placement.residual,
2045
+ })),
2046
+ });
1751
2047
  const distinct: typeof measured = [];
1752
2048
  for (const m of measured) {
1753
2049
  const same = distinct.some(