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/cli.ts +8 -5
- package/docs/AUTHORING.md +155 -27
- package/docs/INGEST.md +20 -13
- package/docs/MOTION.md +31 -17
- package/package.json +1 -1
- package/src/atlas.ts +145 -24
- package/src/check.ts +22 -0
- package/src/compile.ts +6 -1
- package/src/keys.ts +87 -0
- package/src/motion.ts +7 -1
- package/src/pose.ts +260 -13
- package/src/render.ts +192 -22
- package/src/rig.ts +46 -2
- package/src/validate.ts +34 -4
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
|
|
157
|
-
* parts that
|
|
158
|
-
* 2.0 px off against a 2 px bar
|
|
159
|
-
*
|
|
160
|
-
*
|
|
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
|
-
/**
|
|
247
|
-
|
|
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.
|
|
288
|
-
rateAbove: 0.
|
|
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.
|
|
335
|
-
//
|
|
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)
|
|
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
|
-
|
|
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
|
|
1134
|
-
*
|
|
1135
|
-
*
|
|
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
|
-
/**
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
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
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
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
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
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
|
|
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
|
}
|