spine-rigc 1.4.0 β†’ 1.5.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/src/pose.ts CHANGED
@@ -153,11 +153,23 @@ const COARSE_PART_THICKNESS = 4;
153
153
  * the search misses fell from 65 to 15, for 10–14% more CPU over the grid.
154
154
  *
155
155
  * πŸ”Έ Not free of losses, and they are stated rather than averaged away: 9 trials
156
- * that placed on the quarter grid do not on this one. Five of them are blurred
157
- * parts that now come back ambiguous between two scales at the same spot, or
158
- * 2.0 px off against a 2 px bar; three are a basin reached and then polished
159
- * onto a worse placement beside it, and one a basin ranked out of the
160
- * refinement's twelve β€” the refinement, not this grid.
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.
161
173
  */
162
174
  export const COARSE_STRIDE_FRACTION = 0.125;
163
175
 
@@ -243,8 +255,84 @@ export const BACKGROUND_BORDER_SHARE = 0.6;
243
255
  */
244
256
  const MINIMA_PER_SCALE = 5;
245
257
 
246
- /** How many candidates survive each refinement level. */
247
- const REFINE_CANDIDATES = 12;
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;
275
+
276
+ /**
277
+ * The factors 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
+ * and a quarter of one (issue #886); the better of the two re-fits wins.
280
+ *
281
+ * πŸ” What the pattern search cannot do, and the trace table of issue #877 is
282
+ * how it was seen: every probe moves ONE degree of freedom, and the objective
283
+ * couples scale to position along a diagonal valley β€” a part shrunk a little
284
+ * fits best a pixel or two off its truth, so from there every single-axis probe
285
+ * is worse and every joint move is better. In 12 of the 14 missed trials the
286
+ * truth's candidate reached full resolution ranked first or second, the
287
+ * full-resolution objective preferred the truth to everything kept, and the
288
+ * polish stopped 2–7 px off it at a scale of 0.64–0.96. Not one polish ever
289
+ * accepted a worse residual β€” a polish moves only on a strictly lower probe of
290
+ * its own level's objective, and `PO26` holds that β€” so the rise from one level
291
+ * to the next is a change of objective, never a step taken uphill.
292
+ *
293
+ * ⭐ So the escape re-fits position at the new scale before comparing β€” the
294
+ * joint move, taken one axis at a time β€” and a better point restarts the polish
295
+ * there. UP only, and that is derived rather than tuned: the objective charges a
296
+ * part pixel that lands off the figure and charges nothing for figure left
297
+ * uncovered, so a shrunk placement is the cheap error the reduced levels make β€”
298
+ * every stuck point in the table sat below scale 1, none above.
299
+ *
300
+ * πŸ“ Measured on the grid with `REFINE_CANDIDATES` at fifteen: 227 β†’ 237 trials
301
+ * found, 12 gained, 2 lost, search misses 14 β†’ 8, for 43% more refinement
302
+ * samples (the coarse pass is untouched). Both ways at twelve found 236 (12
303
+ * gained, 3 lost) for 38% more against up only's 21%; a second step each way,
304
+ * 237 (15, 5) for 76%. ⚠️ The two it loses are one shape, a scale twin at the
305
+ * same spot: the blurred 64 px mouth (walk) keeps its best at 0.9 px and now
306
+ * reports a second placement 1.8 px off at scale 0.825 within the ambiguity
307
+ * margin, and the blurred 24 px front shin (walk) now places AT its truth
308
+ * (0.03 px, 0.00989) where it placed 0.57 px off at 0.0191 β€” the old answer is
309
+ * still reported beside it, 0.0094 above, inside the 0.01 absolute margin.
310
+ *
311
+ * πŸ” The quarter rung is issue #886, and the fixed-scale profile is what
312
+ * named it. MOTION.md Β§6's post (14x96, pose A, `--scale 0.85,1.2`) came back
313
+ * at scale 0.917 and residual 0.0649 where the page had recorded 0.975 at
314
+ * 0.0589 β€” the old placement still scores 0.0589 on this objective, so the
315
+ * search missed it. The trace: the coarse cell nearest the truth (2.4 px off
316
+ * it, at the 2x level) kept a half-turned post, its polish ended 6.7 px from
317
+ * the truth, the full-resolution rotation re-grid set it upright there, and
318
+ * the polish climbed the scale–position valley to 0.917.
319
+ * The half-rung escape then tried 1.029: best position there 0.0980, worse
320
+ * than 0.0649, so it declined β€” while the best position at every scale
321
+ * between 0.94 and 1.00 scores 0.0619–0.0589. A part whose material fills its
322
+ * image pays for every pixel pushed past the figure, so its profile above the
323
+ * truth is a cliff, and half a rung stepped over the basin onto it. A quarter
324
+ * rung lands at 0.971 (0.0593, 0.1 px off the page's placement).
325
+ *
326
+ * πŸ“ Both, not the quarter alone, and measured on the grid rather than argued:
327
+ * the quarter alone found 234 (3 gained, 6 lost β€” the native arms #877 was
328
+ * made for need the half); half then quarter, the quarter tried only when the
329
+ * half declines, 237 (1, 1); both, the better kept, 238 (2 gained, 1 lost) and
330
+ * misses 8 β†’ 6, for 16% more user CPU over the grid. The one it loses is a
331
+ * margin reading of the kind named above: the blurred 32 px front shin (walk)
332
+ * now reports its truth as its BEST (0.07 px, 0.00968) with the old 0.51 px
333
+ * answer 0.0045 above it, inside the 0.01 absolute margin.
334
+ */
335
+ const POLISH_SCALE_ESCAPES = [2 ** (1 / (4 * SCALE_STEPS_PER_OCTAVE)), 2 ** (1 / (2 * SCALE_STEPS_PER_OCTAVE))];
248
336
 
249
337
  /** Sample budgets per stage. The reported residual uses every pixel regardless. */
250
338
  const COARSE_SAMPLES = 96;
@@ -284,8 +372,8 @@ export const POSE_FLOOR = {
284
372
  span: 24,
285
373
  detail: 0.5,
286
374
  /** Found share of the grid's trials under `detail`, and over it β€” both measured by `deriveFloor`. */
287
- rateBelow: 0.082,
288
- rateAbove: 0.728,
375
+ rateBelow: 0.066,
376
+ rateAbove: 0.772,
289
377
  };
290
378
 
291
379
  /** Which side of `POSE_FLOOR` a part is on, from its own longest side in part pixels and its detail. */
@@ -331,8 +419,11 @@ export function legibilityReading(l: Omit<PoseLegibility, 'reading'>, verdict: '
331
419
  // occurs: on the grid after it, 10 ambiguous trials above the floor had a
332
420
  // truth scoring better than both answers β€” native 24 px gun and shin, 48 px
333
421
  // arm, 96 px goggles twice, and blurred gun, goggles and rear shin from 48 px
334
- // up. Nine of the ten sat 2–6 px from the truth rather than on another hill. So the sentence names that reading too, with the one remedy that
335
- // separates it: a window.
422
+ // up. Nine of the ten sat 2–6 px from the truth rather than on another hill.
423
+ // Issue #877's polish took that to 6 β€” native 24 px gun, 96 px goggles, and
424
+ // blurred goggles and gun from 128 px and rear shin at 48 px β€” five of them
425
+ // 2.1–2.7 px off, the rear shin 19 px. So the sentence names that reading
426
+ // too, with the one remedy that separates it: a window.
336
427
  return (
337
428
  `${figures} β€” above the measured floor (AUTHORING Β§11.5: ${floorClause()}), so size and texture do not explain this; ` +
338
429
  (verdict === 'refused'
@@ -567,6 +658,67 @@ export interface PoseOptions {
567
658
  scale?: { min: number; max: number };
568
659
  rotation?: { minDeg: number; maxDeg: number };
569
660
  maxResidual?: number;
661
+ /**
662
+ * An instrument's sink, never read by the search itself: when present, every
663
+ * level of every part's refinement is appended to it. See `PoseTrace`.
664
+ */
665
+ trace?: PoseTrace;
666
+ }
667
+
668
+ /** A search candidate as the trace states it: where the part image's centre would land, frame pixels. */
669
+ export interface PoseTraceCandidate {
670
+ x: number;
671
+ y: number;
672
+ rotationDeg: number;
673
+ scale: number;
674
+ /** The level's own sampled objective β€” not the reported, every-pixel residual. */
675
+ residual: number;
676
+ }
677
+
678
+ /** One accepted move of a polish: the residual it moved to and the steps it moved with. */
679
+ export interface PoseTraceStep {
680
+ residual: number;
681
+ translate: number;
682
+ rotate: number;
683
+ scale: number;
684
+ /** The accepted probe's scale or rotation was held by its window rather than taken as stepped. */
685
+ clamped: boolean;
686
+ /** A restart from a converged point to a different scale, rather than a step of the pattern. */
687
+ escape: boolean;
688
+ }
689
+
690
+ export interface PoseTraceLevel {
691
+ part: string;
692
+ /** Pyramid index; 0 is full resolution. */
693
+ level: number;
694
+ reduction: number;
695
+ /** How many of the candidates handed down this level took, before any rotation branching. */
696
+ keep: number;
697
+ /** The seeds this level polished, in the order they were ranked going in. */
698
+ seeds: PoseTraceCandidate[];
699
+ /** Per seed, this level's objective at the seed β€” where its polish started from. */
700
+ starts: number[];
701
+ /** Per seed, the moves its polish accepted, in order. */
702
+ paths: PoseTraceStep[][];
703
+ /** Per seed, where its polish ended. */
704
+ polished: PoseTraceCandidate[];
705
+ /** The candidates this level hands on, ranked and deduplicated. */
706
+ out: PoseTraceCandidate[];
707
+ /** This level's objective at `PoseTrace.probe`, when one was given. */
708
+ probeResidual: number | null;
709
+ }
710
+
711
+ /**
712
+ * The refinement written down level by level, for an instrument that knows
713
+ * where the truth is and needs to say where the search left it (issue #877).
714
+ * Pure bookkeeping: a search with a trace returns the same report as one without.
715
+ */
716
+ export interface PoseTrace {
717
+ /** A placement, in the report's own space, to score at every level alongside the candidates. */
718
+ probe?: { x: number; y: number; rotationDeg: number; scale: number };
719
+ levels: PoseTraceLevel[];
720
+ /** Per part, every candidate that reached the report, with its every-pixel residual, best first. */
721
+ measured: { part: string; candidates: PoseTraceCandidate[] }[];
570
722
  }
571
723
 
572
724
  // ---------------------------------------------------------------------------
@@ -1095,16 +1247,47 @@ function polish(
1095
1247
  * default run moves.
1096
1248
  */
1097
1249
  rotationBounds: { min: number; max: number; wraps: boolean },
1250
+ /** Scale factors tried from the point the steps converged on; a better one restarts the polish there. */
1251
+ escapes: readonly number[],
1252
+ /** An instrument's record of where the polish started and the moves it accepted; the search never reads it. */
1253
+ path?: { start: number; steps: PoseTraceStep[] },
1098
1254
  ): Candidate {
1099
1255
  const clamp = (v: number): number => Math.min(bounds.max, Math.max(bounds.min, v));
1100
1256
  const hold = (v: number): number =>
1101
1257
  rotationBounds.wraps ? v : Math.min(rotationBounds.max, Math.max(rotationBounds.min, v));
1102
1258
  let cur: Candidate = { ...start, residual: residualAt(level, plate, s, start, smooth) };
1259
+ if (path !== undefined) path.start = cur.residual;
1103
1260
  let dt = step.translate;
1104
1261
  let dr = step.rotate;
1105
1262
  let ds = step.scale;
1106
1263
  for (let guard = 0; guard < 200; guard++) {
1107
- if (dt <= floor.translate && dr <= floor.rotate && ds <= floor.scale) break;
1264
+ if (dt <= floor.translate && dr <= floor.rotate && ds <= floor.scale) {
1265
+ let jump = cur;
1266
+ for (const f of escapes) {
1267
+ const scale = clamp(cur.scale * f);
1268
+ if (scale === cur.scale) continue;
1269
+ const refit = polish(
1270
+ level,
1271
+ plate,
1272
+ s,
1273
+ { ...cur, scale },
1274
+ { translate: step.translate, rotate: 0, scale: 0 },
1275
+ { translate: floor.translate, rotate: 0, scale: 0 },
1276
+ smooth,
1277
+ bounds,
1278
+ rotationBounds,
1279
+ [],
1280
+ );
1281
+ if (refit.residual < jump.residual) jump = refit;
1282
+ }
1283
+ if (jump === cur) break;
1284
+ path?.steps.push({ residual: jump.residual, translate: dt, rotate: dr, scale: jump.scale / cur.scale - 1, clamped: false, escape: true });
1285
+ cur = jump;
1286
+ dt = step.translate;
1287
+ dr = step.rotate;
1288
+ ds = step.scale;
1289
+ continue;
1290
+ }
1108
1291
  const probes: Candidate[] = [];
1109
1292
  const push = (cand: Omit<Candidate, 'residual'>): void => {
1110
1293
  probes.push({ ...cand, residual: residualAt(level, plate, s, { ...cand, residual: 0 }, smooth) });
@@ -1139,6 +1322,12 @@ function polish(
1139
1322
  ds /= 2;
1140
1323
  continue;
1141
1324
  }
1325
+ if (path !== undefined) {
1326
+ const clamped =
1327
+ (best.scale !== cur.scale && best.scale !== cur.scale * (1 + ds) && best.scale !== cur.scale * (1 - ds)) ||
1328
+ (best.rotDeg !== cur.rotDeg && best.rotDeg !== cur.rotDeg + dr && best.rotDeg !== cur.rotDeg - dr);
1329
+ path.steps.push({ residual: best.residual, translate: dt, rotate: dr, scale: ds, clamped, escape: false });
1330
+ }
1142
1331
  cur = best;
1143
1332
  }
1144
1333
  return cur;
@@ -1553,6 +1742,7 @@ export function estimatePose(options: PoseOptions): PoseReport {
1553
1742
  maxResidual,
1554
1743
  { min: scaleMin, max: scaleMax },
1555
1744
  rotationBounds,
1745
+ options.trace,
1556
1746
  ),
1557
1747
  );
1558
1748
  }
@@ -1569,6 +1759,7 @@ function placePart(
1569
1759
  maxResidual: number,
1570
1760
  scaleBounds: { min: number; max: number },
1571
1761
  rotationBounds: { min: number; max: number; wraps: boolean },
1762
+ trace?: PoseTrace,
1572
1763
  ): PosePart {
1573
1764
  const scaleMin = scaleBounds.min;
1574
1765
  /** The scale the sample sets are sized for β€” the middle of the window, and NOT the scale under test. */
@@ -1766,7 +1957,11 @@ function placePart(
1766
1957
  }
1767
1958
  seeds.push(start);
1768
1959
  }
1769
- candidates = seeds.map((seed) => polish(level, plate, s, seed, step, floor, smooth, scaleBounds, rotationBounds));
1960
+ const paths = seeds.map((): { start: number; steps: PoseTraceStep[] } => ({ start: 0, steps: [] }));
1961
+ candidates = seeds.map((seed, i) =>
1962
+ polish(level, plate, s, seed, step, floor, smooth, scaleBounds, rotationBounds, li === 0 ? POLISH_SCALE_ESCAPES : [], trace === undefined ? undefined : paths[i]),
1963
+ );
1964
+ const polished = candidates.slice();
1770
1965
  candidates.sort((a, b) => a.residual - b.residual);
1771
1966
  // ⚠️ Eight branches that walked to one optimum are one candidate, not eight β€”
1772
1967
  // and the radius has to scale with the PART rather than be a pixel count.
@@ -1775,6 +1970,47 @@ function placePart(
1775
1970
  // instrument exists to keep: with a fixed one-pixel radius the fixture lost
1776
1971
  // one of its two identical arms.
1777
1972
  candidates = dedupe(candidates, Math.max(1, (0.2 * span * scaleReference) / level.reduction), 5, 1.03);
1973
+ if (trace !== undefined) {
1974
+ const ou = part.width / 2 - anchorX;
1975
+ const ov = part.height / 2 - anchorY;
1976
+ const said = (c: Candidate): PoseTraceCandidate => {
1977
+ const cos = Math.cos(c.rotDeg * DEG) * c.scale;
1978
+ const sin = Math.sin(c.rotDeg * DEG) * c.scale;
1979
+ return {
1980
+ x: c.cx * level.reduction + ou * cos - ov * sin,
1981
+ y: c.cy * level.reduction + ou * sin + ov * cos,
1982
+ rotationDeg: normaliseDegrees(c.rotDeg),
1983
+ scale: c.scale,
1984
+ residual: c.residual,
1985
+ };
1986
+ };
1987
+ let probeResidual: number | null = null;
1988
+ if (trace.probe !== undefined) {
1989
+ const q = trace.probe;
1990
+ const cos = Math.cos(q.rotationDeg * DEG) * q.scale;
1991
+ const sin = Math.sin(q.rotationDeg * DEG) * q.scale;
1992
+ const at: Candidate = {
1993
+ cx: (q.x - (ou * cos - ov * sin)) / level.reduction,
1994
+ cy: (q.y - (ou * sin + ov * cos)) / level.reduction,
1995
+ rotDeg: q.rotationDeg,
1996
+ scale: q.scale,
1997
+ residual: 0,
1998
+ };
1999
+ probeResidual = residualAt(level, plate, s, at, smooth);
2000
+ }
2001
+ trace.levels.push({
2002
+ part: name,
2003
+ level: li,
2004
+ reduction: level.reduction,
2005
+ keep,
2006
+ seeds: seeds.map(said),
2007
+ starts: paths.map((p) => p.start),
2008
+ paths: paths.map((p) => p.steps),
2009
+ polished: polished.map(said),
2010
+ out: candidates.map(said),
2011
+ probeResidual,
2012
+ });
2013
+ }
1778
2014
  if (li > 0) {
1779
2015
  candidates = candidates.map((c) => ({ ...c, cx: c.cx * 2, cy: c.cy * 2 }));
1780
2016
  // A rotation-free part keeps its step at zero all the way down, so the
@@ -1812,6 +2048,7 @@ function placePart(
1812
2048
  true,
1813
2049
  scaleBounds,
1814
2050
  rotationBounds,
2051
+ [],
1815
2052
  ),
1816
2053
  );
1817
2054
  }
@@ -1822,6 +2059,16 @@ function placePart(
1822
2059
  // candidates that walked to the same optimum are one answer, not two.
1823
2060
  const measured = candidates.map((cand) => ({ cand, placement: toPlacement(part, anchorX, anchorY, cand, measure(levels[0], plates[0], part, anchorX, anchorY, cand)) }));
1824
2061
  measured.sort((a, b) => a.placement.residual - b.placement.residual || b.placement.footprint - a.placement.footprint);
2062
+ trace?.measured.push({
2063
+ part: name,
2064
+ candidates: measured.map((m) => ({
2065
+ x: m.placement.x,
2066
+ y: m.placement.y,
2067
+ rotationDeg: m.placement.rotationDeg,
2068
+ scale: m.placement.scale,
2069
+ residual: m.placement.residual,
2070
+ })),
2071
+ });
1825
2072
  const distinct: typeof measured = [];
1826
2073
  for (const m of measured) {
1827
2074
  const same = distinct.some(
package/src/render.ts CHANGED
@@ -1130,9 +1130,17 @@ export interface GeometryFile {
1130
1130
  }
1131
1131
 
1132
1132
  /**
1133
- * A number the export could not print as a number β€” refused rather than written,
1134
- * because `JSON.stringify` writes `NaN` and `Infinity` as `null` and a consumer
1135
- * would read a hole in the geometry as a vertex at nothing.
1133
+ * A pose holding a number that is not finite β€” refused by the bone or the vertex
1134
+ * that holds it, and the value, rather than drawn, framed or written.
1135
+ *
1136
+ * Two callers throw it, with one sentence between them (`nonFiniteSentence`):
1137
+ * the geometry export, because `JSON.stringify` writes `NaN` and `Infinity` as
1138
+ * `null` and a consumer would read a hole in the geometry as a vertex at
1139
+ * nothing; and `framingViewport`, because a box over an infinite vertex is no
1140
+ * box at all (issue #873). Before that second caller the framing answered `null`
1141
+ * for it, which `render` prints as "posed no drawable attachment" β€” true of a
1142
+ * skeleton that draws nothing and false of this one, whose attachment is there
1143
+ * and posed to a number no picture can hold.
1136
1144
  */
1137
1145
  export class GeometryError extends Error {}
1138
1146
 
@@ -1250,28 +1258,159 @@ function restOf(data: SkeletonData, skin: string | undefined, shown: AttachmentP
1250
1258
  return out;
1251
1259
  }
1252
1260
 
1253
- /** Throw a `GeometryError` at the first number in `file` that is not finite, naming where it sits. */
1254
- function refuseNonFinite(file: GeometryFile): void {
1255
- const check = (where: string, entries: AttachmentPose[] | AttachmentRest[], bones: GeometryBone[]): void => {
1256
- for (const bone of bones) {
1257
- for (const field of ['a', 'b', 'c', 'd', 'worldX', 'worldY'] as const) {
1258
- if (!Number.isFinite(bone[field])) {
1259
- throw new GeometryError(`${where}: bone ${JSON.stringify(bone.name)} has ${field} ${String(bone[field])}; a world transform is finite`);
1260
- }
1261
+ /** The bone fields a world transform is made of β€” what `firstNonFinite` reads off a bone. */
1262
+ type WorldTransform = Pick<GeometryBone, 'name' | 'a' | 'b' | 'c' | 'd' | 'worldX' | 'worldY'>;
1263
+
1264
+ /** The fields of an attachment entry `firstNonFinite` reads β€” a frame's `AttachmentPose` or a rest entry. */
1265
+ type PosedVertices = Pick<AttachmentPose, 'slot' | 'attachment' | 'vertices'>;
1266
+
1267
+ /**
1268
+ * The sentence for the first number at `where` that is not finite β€” bones before
1269
+ * vertices, since a bone that overflowed is the cause and its vertices the
1270
+ * symptom β€” or `null` when every number there is finite.
1271
+ */
1272
+ function firstNonFinite(where: string, entries: readonly PosedVertices[], bones: readonly WorldTransform[]): string | null {
1273
+ for (const bone of bones) {
1274
+ for (const field of ['a', 'b', 'c', 'd', 'worldX', 'worldY'] as const) {
1275
+ if (!Number.isFinite(bone[field])) {
1276
+ return `${where}: bone ${JSON.stringify(bone.name)} has ${field} ${String(bone[field])}; a world transform is finite`;
1261
1277
  }
1262
1278
  }
1263
- for (const entry of entries) {
1264
- const bad = entry.vertices.findIndex((value) => !Number.isFinite(value));
1265
- if (bad === -1) continue;
1266
- throw new GeometryError(
1267
- `${where}: slot ${JSON.stringify(entry.slot)} attachment ${JSON.stringify(entry.attachment)} vertex ` +
1268
- `${Math.floor(bad / 2)} has ${bad % 2 === 0 ? 'x' : 'y'} ${String(entry.vertices[bad])}; a posed vertex is finite`,
1269
- );
1279
+ }
1280
+ for (const entry of entries) {
1281
+ const bad = entry.vertices.findIndex((value) => !Number.isFinite(value));
1282
+ if (bad === -1) continue;
1283
+ return (
1284
+ `${where}: slot ${JSON.stringify(entry.slot)} attachment ${JSON.stringify(entry.attachment)} vertex ` +
1285
+ `${Math.floor(bad / 2)} has ${bad % 2 === 0 ? 'x' : 'y'} ${String(entry.vertices[bad])}; a posed vertex is finite`
1286
+ );
1287
+ }
1288
+ return null;
1289
+ }
1290
+
1291
+ /**
1292
+ * The same sentence for one skeleton as it stands posed now β€” its bones, then
1293
+ * the vertices of every region and mesh its slots show, in draw order β€” or
1294
+ * `null` when every one of those numbers is finite.
1295
+ *
1296
+ * ⭐ This is how `A10_NO_NAN_AFTER_STEPPING` reads each pose it steps (issue
1297
+ * #882), so the gate and the renderer hold one definition of "not finite": the
1298
+ * six terms `firstNonFinite` reads off a bone, and the vertices the runtime
1299
+ * computes from them. Before it A10 read the world POSITION alone, and a bone at
1300
+ * `rotation: 1e309` β€” finite position, NaN `a`, `b`, `c`, `d` β€” was gated green
1301
+ * and then refused here. The vertices are read too because a bone can be finite
1302
+ * and still carry one that is not: a two-bone `scaleX` chain of 1e154 Γ— 1e154
1303
+ * leaves the child's `a` at 1e308, finite, and every vertex of its attachment
1304
+ * past the largest double. Only computed once every bone is finite, so a
1305
+ * broken bone is named as the cause rather than through its vertices.
1306
+ */
1307
+ export function nonFiniteOfPosed(where: string, skeleton: Skeleton): string | null {
1308
+ const bones: WorldTransform[] = skeleton.bones.map((bone) => {
1309
+ const { a, b, c, d, worldX, worldY } = bone.appliedPose;
1310
+ return { name: bone.data.name, a, b, c, d, worldX, worldY };
1311
+ });
1312
+ const found = firstNonFinite(where, [], bones);
1313
+ if (found !== null) return found;
1314
+ const entries: PosedVertices[] = [];
1315
+ for (const slot of skeleton.drawOrder.appliedPose) {
1316
+ const attachment = slot.appliedPose.attachment;
1317
+ if (!(attachment instanceof MeshAttachment) && !(attachment instanceof RegionAttachment)) continue;
1318
+ entries.push({ slot: slot.data.name, attachment: attachment.name, vertices: worldVerticesOf(skeleton, slot, attachment) });
1319
+ }
1320
+ return firstNonFinite(where, entries, []);
1321
+ }
1322
+
1323
+ /**
1324
+ * How a sampled frame is named in that sentence: the animation, the frame's
1325
+ * index at the rate it was sampled and its time β€” or the bare index for the one
1326
+ * frame of a skeleton with no animation, whose setup pose is checked first.
1327
+ */
1328
+ function frameWhere(animation: string | null, index: number, time: number, fps: number): string {
1329
+ if (animation === null) return `frame ${index}`;
1330
+ return `animation ${JSON.stringify(animation)} frame ${index} at ${fps} fps (t=${time.toFixed(4)}s)`;
1331
+ }
1332
+
1333
+ /** One sampled frame's numbers, under the name the sentence gives it. */
1334
+ interface NamedPose {
1335
+ where: string;
1336
+ attachments: readonly PosedVertices[];
1337
+ bones: readonly WorldTransform[];
1338
+ }
1339
+
1340
+ /**
1341
+ * ⭐ **The one derivation of the non-finite sentence** (issue #873): the setup
1342
+ * pose, then every frame in order, then the rest table. The export and the
1343
+ * framing both reach it, so `render` and `render --geometry` refuse one planted
1344
+ * overflow in the same words.
1345
+ *
1346
+ * The setup pose first because a setup bone that overflowed is named there as
1347
+ * the BONE, and the rest table β€” the setup's bones with no deform β€” would name
1348
+ * the same fault one step on, as a vertex. Rest is still checked, last: it holds
1349
+ * attachments the setup pose does not show.
1350
+ */
1351
+ function nonFiniteSentence(
1352
+ setup: Pick<NamedPose, 'attachments' | 'bones'>,
1353
+ frames: readonly NamedPose[],
1354
+ rest: readonly PosedVertices[],
1355
+ ): string | null {
1356
+ const first = firstNonFinite('the setup pose', setup.attachments, setup.bones);
1357
+ if (first !== null) return first;
1358
+ for (const frame of frames) {
1359
+ const found = firstNonFinite(frame.where, frame.attachments, frame.bones);
1360
+ if (found !== null) return found;
1361
+ }
1362
+ return firstNonFinite('the rest table', rest, []);
1363
+ }
1364
+
1365
+ /** Throw a `GeometryError` at the first number in `file` that is not finite, naming where it sits. */
1366
+ function refuseNonFinite(file: GeometryFile): void {
1367
+ const sentence = nonFiniteSentence(
1368
+ file.setup,
1369
+ file.frames.map((frame) => ({ ...frame, where: frameWhere(file.animation, frame.index, frame.time, file.fps) })),
1370
+ file.rest,
1371
+ );
1372
+ if (sentence !== null) throw new GeometryError(sentence);
1373
+ }
1374
+
1375
+ /**
1376
+ * Where a skeleton's pose is not finite, as the sentence the geometry export
1377
+ * refuses on β€” or `null` when every bone and vertex of it is finite.
1378
+ *
1379
+ * Each set is sampled at its own rate, with its bones and whole attachments β€”
1380
+ * `animation: null` is the setup pose alone β€” so the frame it names is a frame of
1381
+ * the caller's own grid. It samples again rather than taking the caller's
1382
+ * frames, because a caller that only draws sampled no bones; it is run only
1383
+ * once the caller has found a number that is not finite, so a finite pose never
1384
+ * pays for it.
1385
+ */
1386
+ export function nonFinitePoseOf(
1387
+ data: SkeletonData,
1388
+ skin: string | undefined,
1389
+ sets: ReadonlyArray<{ animation: string | null; fps: number }>,
1390
+ ): string | null {
1391
+ const opts: PoseOptions = { ...(skin === undefined ? {} : { skin }), bones: true, geometry: true };
1392
+ // Every attachment list the frames showed, for the rest table's roster.
1393
+ const shown: AttachmentPose[][] = [];
1394
+ const named = (frame: Frame, where: string): NamedPose => {
1395
+ if (frame.bones === undefined || frame.attachments === undefined) {
1396
+ throw new Error(`${where} was sampled without { bones: true, geometry: true }`);
1270
1397
  }
1398
+ shown.push(frame.attachments);
1399
+ return { where, attachments: frame.attachments, bones: frame.bones };
1271
1400
  };
1272
- check('the rest table', file.rest, []);
1273
- check('the setup pose', file.setup.attachments, file.setup.bones);
1274
- for (const frame of file.frames) check(`frame ${frame.index}`, frame.attachments, frame.bones);
1401
+ const setup = named(sampleSetupPose(data, opts)[0], 'the setup pose');
1402
+ const frames = sets.flatMap(({ animation, fps }) =>
1403
+ animation === null
1404
+ ? []
1405
+ : sampleAnimation(data, animation, fps, opts).map((frame) =>
1406
+ named(frame, frameWhere(animation, frame.index, frame.time, fps)),
1407
+ ),
1408
+ );
1409
+ // Not read unless the setup pose and every frame are finite: `restOf` poses
1410
+ // what they show, and would be posing the same overflow a third time.
1411
+ const found = nonFiniteSentence(setup, frames, []);
1412
+ if (found !== null) return found;
1413
+ return nonFiniteSentence({ attachments: [], bones: [] }, [], restOf(data, skin, shown));
1275
1414
  }
1276
1415
 
1277
1416
  /**
@@ -1685,6 +1824,10 @@ export function trimmedUnionBounds(
1685
1824
  *
1686
1825
  * Measuring the box densely and once makes the framing a property of the SHOT,
1687
1826
  * so every rate of one skeleton lands on the same pixels.
1827
+ *
1828
+ * `null` means the skeleton posed no vertex at all β€” nothing to draw. A pose
1829
+ * holding a vertex at Infinity or NaN is refused by a `GeometryError` naming the
1830
+ * bone or vertex and its value (issue #873), never answered `null`.
1688
1831
  */
1689
1832
  export function framingViewport(data: SkeletonData, maxSide: number, opts?: PoseOptions): Viewport | null {
1690
1833
  // The skin belongs here as much as in the frames: the union box is over the
@@ -1708,8 +1851,35 @@ export function framingViewport(data: SkeletonData, maxSide: number, opts?: Pose
1708
1851
  data.animations.length === 0
1709
1852
  ? [sampleSetupPose(data, framed)]
1710
1853
  : data.animations.map((a) => sampleAnimation(data, a.name, FRAMING_FPS, framed));
1854
+ // ⚠️ Two different reasons a box is not finite, told apart here and nowhere
1855
+ // else (issue #873). A skeleton that posed no vertex at all has nothing to
1856
+ // draw, and that is `null`. One that posed a vertex at Infinity or NaN has a
1857
+ // drawable attachment in a place no box can hold β€” so it is refused by the
1858
+ // bone or vertex and its value, in the geometry export's own sentence. Before
1859
+ // this, the first case's `null` covered both, and a single overflowing bone
1860
+ // among finite ones reached neither: its box was finite on one side, and
1861
+ // `render` wrote a NaN-by-NaN frame set with exit 0.
1862
+ const posedAny = sets.some((frames) => frames.some((frame) => frame.pieces.some((piece) => piece.world.length > 0)));
1863
+ if (!posedAny) return null;
1711
1864
  const box = unionBounds(sets);
1712
- if (!Number.isFinite(box.minX)) return null;
1865
+ if (![box.minX, box.minY, box.maxX, box.maxY].every(Number.isFinite)) {
1866
+ const found = nonFinitePoseOf(
1867
+ data,
1868
+ framed.skin,
1869
+ data.animations.length === 0
1870
+ ? [{ animation: null, fps: FRAMING_FPS }]
1871
+ : data.animations.map((a) => ({ animation: a.name, fps: FRAMING_FPS })),
1872
+ );
1873
+ // Reaching here with nothing found would mean the pieces and the whole
1874
+ // attachments disagree about one pose β€” a defect here, said as one.
1875
+ if (found === null) {
1876
+ throw new Error(
1877
+ `the framing box is not finite (${box.minX}, ${box.minY}, ${box.maxX}, ${box.maxY}) and no bone or ` +
1878
+ 'vertex of the pose is β€” the drawn pieces and the attachments were posed differently',
1879
+ );
1880
+ }
1881
+ throw new GeometryError(found);
1882
+ }
1713
1883
  const pad = Math.max(box.maxX - box.minX, box.maxY - box.minY) * PAD;
1714
1884
  return viewportFor(box.minX - pad, box.minY - pad, box.maxX + pad, box.maxY + pad, maxSide);
1715
1885
  }