spine-rigc 0.27.0 → 0.28.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/cli.ts +159 -10
- package/docs/AUTHORING.md +182 -3
- package/docs/INGEST.md +41 -1
- package/package.json +1 -1
- package/src/diff.ts +235 -9
- package/src/pose.ts +195 -21
package/cli.ts
CHANGED
|
@@ -66,10 +66,24 @@ import {
|
|
|
66
66
|
type DeformKeyMeasure,
|
|
67
67
|
type DeformSpan,
|
|
68
68
|
} from './src/deformmeasure.ts';
|
|
69
|
-
import {
|
|
69
|
+
import {
|
|
70
|
+
diffLines,
|
|
71
|
+
diffSkeletons,
|
|
72
|
+
reportedFigures,
|
|
73
|
+
sectionFigures,
|
|
74
|
+
type DiffAnimationPair,
|
|
75
|
+
type DiffReport,
|
|
76
|
+
} from './src/diff.ts';
|
|
70
77
|
import { ingest, IngestError, IngestSpecRefused, INGEST_GUTTERS, type IngestFinding, type IngestStage } from './src/ingest.ts';
|
|
71
78
|
import { copyAtlasPages } from './src/emit.ts';
|
|
72
|
-
import {
|
|
79
|
+
import {
|
|
80
|
+
DEFAULT_PADDING,
|
|
81
|
+
DEFAULT_PAGE_SIZE,
|
|
82
|
+
packAtlas,
|
|
83
|
+
pageFootprint,
|
|
84
|
+
parseAtlasText,
|
|
85
|
+
type AtlasRegion,
|
|
86
|
+
} from './src/atlas.ts';
|
|
73
87
|
import { parseJsonWithPosition } from './src/json-position.ts';
|
|
74
88
|
import { KEY_TIME_EPSILON } from './src/timelines.ts';
|
|
75
89
|
import { findRung, RUNG_IDS, type RungSkeleton } from './src/ladder.ts';
|
|
@@ -218,13 +232,16 @@ const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images',
|
|
|
218
232
|
/**
|
|
219
233
|
* The flags a command is allowed to spell more than once.
|
|
220
234
|
*
|
|
221
|
-
*
|
|
222
|
-
* candidates
|
|
235
|
+
* `vote --candidate` is, because a ballot is *by definition* several
|
|
236
|
+
* candidates, and `diff --as` is, because a skeleton has as many shots as it
|
|
237
|
+
* has and one pairing per flag is the only spelling that keeps each pair a pair
|
|
238
|
+
* (issue #720). Everywhere else a repeat is a mistake and is refused: `check
|
|
223
239
|
* --candidate a --candidate b` used to take `b` silently, which is a report
|
|
224
240
|
* about a rig the caller did not think they were asking about.
|
|
225
241
|
*/
|
|
226
242
|
const REPEATABLE_FLAGS: Record<string, ReadonlySet<string>> = {
|
|
227
243
|
vote: new Set(['candidate']),
|
|
244
|
+
diff: new Set(['as']),
|
|
228
245
|
};
|
|
229
246
|
|
|
230
247
|
/**
|
|
@@ -1155,6 +1172,45 @@ function dropLine(dropped: DroppedState): string {
|
|
|
1155
1172
|
return ` DROP ${dropped.slot}/${dropped.state}: ${droppedStateReason(dropped)} (state not emitted)`;
|
|
1156
1173
|
}
|
|
1157
1174
|
|
|
1175
|
+
/**
|
|
1176
|
+
* The rectangle a region occupies **on its page**, for a line that has already
|
|
1177
|
+
* said where the region is — and the empty string where the page rectangle is
|
|
1178
|
+
* the one `bounds:` already states.
|
|
1179
|
+
*
|
|
1180
|
+
* ## The fact no surface an author reads carried (issue #718)
|
|
1181
|
+
*
|
|
1182
|
+
* The atlas line beside this clause prints the DRAWING's size, because that is
|
|
1183
|
+
* what an attachment's width and height mean. A packer that turned the drawing a
|
|
1184
|
+
* quarter to fit it wrote `bounds:` in the drawing's orientation too. So an
|
|
1185
|
+
* author holding the pack and the build report had neither end of the rectangle
|
|
1186
|
+
* they have to cut out of the page to measure a part against a rendered frame —
|
|
1187
|
+
* and the one place rigc printed it was `A06`'s overlap text, reachable only
|
|
1188
|
+
* under `--profile spine-html`. The knowledge was in the tree the whole time:
|
|
1189
|
+
* `pageFootprint` has derived this rectangle for every reader of it since issue
|
|
1190
|
+
* #579, and nothing an author reads said it.
|
|
1191
|
+
*
|
|
1192
|
+
* ⚠️ **The condition is `pageFootprint`'s own answer, not a second reading of
|
|
1193
|
+
* `degrees`.** Re-spelling that predicate here is the exact duplication #579 was
|
|
1194
|
+
* filed on — four readers derived this rectangle and two derived it wrongly — so
|
|
1195
|
+
* the clause asks the function whether its answer differs from the `bounds:`
|
|
1196
|
+
* line, and prints only then.
|
|
1197
|
+
*
|
|
1198
|
+
* 🔸 A consequence worth stating rather than leaving to be discovered: a region
|
|
1199
|
+
* whose KEPT rectangle is square is silent here, because a quarter turn leaves
|
|
1200
|
+
* its footprint the same two numbers and there is nothing the pack does not
|
|
1201
|
+
* already say. The general rule — `bounds` is the unturned size, the footprint
|
|
1202
|
+
* is its transpose at `rotate: 90` and `rotate: 270`, and which way to turn the
|
|
1203
|
+
* rectangle to recover the drawing — belongs to an author's own reading and is
|
|
1204
|
+
* stated in `docs/AUTHORING.md` §0.2, which holds for every region including
|
|
1205
|
+
* that one.
|
|
1206
|
+
*/
|
|
1207
|
+
function pageRectangle(region: AtlasRegion): string {
|
|
1208
|
+
const foot = pageFootprint(region);
|
|
1209
|
+
return foot.width === region.width && foot.height === region.height
|
|
1210
|
+
? ''
|
|
1211
|
+
: `, occupies ${foot.width}x${foot.height}`;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1158
1214
|
function cmdBuild(flags: Record<string, string>): void {
|
|
1159
1215
|
const { label, opts } = resolveCut(flags);
|
|
1160
1216
|
const profile = readProfile(flags);
|
|
@@ -1208,13 +1264,23 @@ function cmdBuild(flags: Record<string, string>): void {
|
|
|
1208
1264
|
// that declares a `scale:` also says so and shows the texels it was read
|
|
1209
1265
|
// from: the size on the left is the DRAWING's and the rectangle is the
|
|
1210
1266
|
// pack's, and issue #267 is the report that printed the second as the first.
|
|
1267
|
+
//
|
|
1268
|
+
// `pageRectangle` closes the line's last silence (issue #718), and it is
|
|
1269
|
+
// placed LAST rather than beside the turn it follows from, which is where
|
|
1270
|
+
// the card put it. The two clauses collide nowhere else, and the collision
|
|
1271
|
+
// is real: `scale 0.5 (373x106 texels)` is itself a size, so
|
|
1272
|
+
// `rotate 90, occupies 106x373 scale 0.5 (…)` reads as though the footprint
|
|
1273
|
+
// were the scaled quantity. As a trailing clause of the whole location
|
|
1274
|
+
// phrase it is unambiguous with a `scale:` line and identical to the card's
|
|
1275
|
+
// wording without one, which is every pack that has no `scale:` to state.
|
|
1211
1276
|
const where =
|
|
1212
1277
|
img.atlas === undefined
|
|
1213
1278
|
? img.page
|
|
1214
1279
|
: `${img.page} @ ${img.atlas.x},${img.atlas.y}${img.atlas.degrees ? ` rotate ${img.atlas.degrees}` : ''}` +
|
|
1215
1280
|
(img.atlasScale === undefined
|
|
1216
1281
|
? ''
|
|
1217
|
-
: ` scale ${img.atlasScale} (${img.atlas.originalWidth}x${img.atlas.originalHeight} texels)`)
|
|
1282
|
+
: ` scale ${img.atlasScale} (${img.atlas.originalWidth}x${img.atlas.originalHeight} texels)`) +
|
|
1283
|
+
pageRectangle(img.atlas);
|
|
1218
1284
|
console.log(` .. ${img.region.padEnd(24)} ${img.width}x${img.height} <- ${where}`);
|
|
1219
1285
|
}
|
|
1220
1286
|
for (const d of result.droppedStates) console.log(dropLine(d));
|
|
@@ -1403,7 +1469,69 @@ function cmdValidate(flags: Record<string, string>, positional: string[]): void
|
|
|
1403
1469
|
console.log('rigc: green');
|
|
1404
1470
|
}
|
|
1405
1471
|
|
|
1406
|
-
|
|
1472
|
+
/**
|
|
1473
|
+
* `--as <candidate>=<reference>`, one pair per occurrence.
|
|
1474
|
+
*
|
|
1475
|
+
* ⚠️ Spelled with a pair where `check --as <name>` takes one name, and the
|
|
1476
|
+
* difference is in what the two commands have on the other side. `check`
|
|
1477
|
+
* measures against a rendered frame SET, which already carries the reference
|
|
1478
|
+
* animation's name in its own directory, so one name closes the gap. `diff` has
|
|
1479
|
+
* two skeletons and either may have its own vocabulary, so one name says which
|
|
1480
|
+
* shot on which side and leaves the other unanswered. The direction — candidate
|
|
1481
|
+
* first — is the one `bonedist`'s correspondence file already writes its
|
|
1482
|
+
* `animations` map in.
|
|
1483
|
+
*
|
|
1484
|
+
* Every refusal here is a UsageError because every one of them is about the
|
|
1485
|
+
* flag's own value, and each names what it read: a value with no `=`, an empty
|
|
1486
|
+
* side, a name repeated on either side, and a name no animation on that side
|
|
1487
|
+
* answers to. ⛔ The last of those is a refusal rather than a dropped pair for
|
|
1488
|
+
* the reason a miss is refused by name everywhere else in this tool — a typo
|
|
1489
|
+
* that quietly measured less would be a report about a pairing the caller did
|
|
1490
|
+
* not ask for.
|
|
1491
|
+
*/
|
|
1492
|
+
function readAnimationPairs(values: string[], candidate: unknown, reference: unknown): DiffAnimationPair[] {
|
|
1493
|
+
const animationsOf = (root: unknown): string[] => {
|
|
1494
|
+
const anims = (root as { animations?: unknown } | null)?.animations;
|
|
1495
|
+
return typeof anims === 'object' && anims !== null && !Array.isArray(anims) ? Object.keys(anims) : [];
|
|
1496
|
+
};
|
|
1497
|
+
const have = { candidate: animationsOf(candidate), reference: animationsOf(reference) };
|
|
1498
|
+
const pairs: DiffAnimationPair[] = [];
|
|
1499
|
+
for (const value of values) {
|
|
1500
|
+
const at = value.indexOf('=');
|
|
1501
|
+
if (at < 0) {
|
|
1502
|
+
throw new UsageError(
|
|
1503
|
+
`--as ${JSON.stringify(value)} is not a pair. It takes <candidate>=<reference> — two animation names joined ` +
|
|
1504
|
+
'by `=`, because diff compares two skeletons and either may have its own name for the shot. The candidate ' +
|
|
1505
|
+
`has [${have.candidate.join(', ') || 'none'}] and the reference has [${have.reference.join(', ') || 'none'}].`,
|
|
1506
|
+
);
|
|
1507
|
+
}
|
|
1508
|
+
const pair = { candidate: value.slice(0, at), reference: value.slice(at + 1) };
|
|
1509
|
+
if (pair.candidate === '' || pair.reference === '') {
|
|
1510
|
+
throw new UsageError(
|
|
1511
|
+
`--as ${JSON.stringify(value)} leaves the ${pair.candidate === '' ? 'candidate' : 'reference'} side empty; ` +
|
|
1512
|
+
'it takes <candidate>=<reference>, a name on each side',
|
|
1513
|
+
);
|
|
1514
|
+
}
|
|
1515
|
+
for (const side of ['candidate', 'reference'] as const) {
|
|
1516
|
+
if (!have[side].includes(pair[side])) {
|
|
1517
|
+
throw new UsageError(
|
|
1518
|
+
`--as ${JSON.stringify(value)} names no ${side} animation: the ${side} has ` +
|
|
1519
|
+
`[${have[side].join(', ') || 'none'}] and not ${JSON.stringify(pair[side])}`,
|
|
1520
|
+
);
|
|
1521
|
+
}
|
|
1522
|
+
if (pairs.some((p) => p[side] === pair[side])) {
|
|
1523
|
+
throw new UsageError(
|
|
1524
|
+
`--as pairs the ${side} animation ${JSON.stringify(pair[side])} twice; each animation may be in one pair, ` +
|
|
1525
|
+
'or the block would compare one shot against two',
|
|
1526
|
+
);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
pairs.push(pair);
|
|
1530
|
+
}
|
|
1531
|
+
return pairs;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
function cmdDiff(flags: Record<string, string>, lists: Record<string, string[]>, positional: string[]): void {
|
|
1407
1535
|
const [candidate, reference] = positional;
|
|
1408
1536
|
if (!candidate || !reference) throw new UsageError('diff takes two paths: <candidate.json> <reference.json>');
|
|
1409
1537
|
const candidatePath = resolve(candidate);
|
|
@@ -1411,7 +1539,10 @@ function cmdDiff(flags: Record<string, string>, positional: string[]): void {
|
|
|
1411
1539
|
for (const path of [candidatePath, referencePath]) {
|
|
1412
1540
|
if (!existsSync(path)) throw new UsageError(`nothing at ${path}`);
|
|
1413
1541
|
}
|
|
1414
|
-
const
|
|
1542
|
+
const candidateJson = readJsonFile(candidatePath);
|
|
1543
|
+
const referenceJson = readJsonFile(referencePath);
|
|
1544
|
+
const animationPairs = readAnimationPairs(lists.as ?? [], candidateJson, referenceJson);
|
|
1545
|
+
const report = diffSkeletons(candidateJson, referenceJson, { animationPairs });
|
|
1415
1546
|
console.log('rigc diff');
|
|
1416
1547
|
for (const line of diffLines(report, { candidate: candidatePath, reference: referencePath })) console.log(line);
|
|
1417
1548
|
if (flags.json !== undefined) {
|
|
@@ -3266,8 +3397,26 @@ const COMMANDS: CommandDoc[] = [
|
|
|
3266
3397
|
},
|
|
3267
3398
|
{
|
|
3268
3399
|
name: 'diff',
|
|
3269
|
-
usage: ['rigc diff <candidate.json> <reference.json> [--json <out>]'],
|
|
3270
|
-
flags: ['json'],
|
|
3400
|
+
usage: ['rigc diff <candidate.json> <reference.json> [--as <candidate>=<reference>]… [--json <out>]'],
|
|
3401
|
+
flags: ['as', 'json'],
|
|
3402
|
+
overrides: {
|
|
3403
|
+
as: {
|
|
3404
|
+
value: '<candidate>=<reference>',
|
|
3405
|
+
meaning:
|
|
3406
|
+
'pair a candidate animation with a reference one, so the name-agnostic `animations` block can be ' +
|
|
3407
|
+
'measured over shots the two files call different things. Repeatable, one pair each. An INPUT and never ' +
|
|
3408
|
+
'derived: two skeletons cannot say which of their shots are the same shot. Without it the block appears ' +
|
|
3409
|
+
'only when each side has exactly one animation, which pairs by position, and is otherwise absent rather ' +
|
|
3410
|
+
'than guessed',
|
|
3411
|
+
},
|
|
3412
|
+
},
|
|
3413
|
+
notes: [
|
|
3414
|
+
'the `animations` block reads two figures once something has paired the shots, exactly as',
|
|
3415
|
+
'`bones` and `slots` do: name-matched, where `names` lives, and name-agnostic over the pair.',
|
|
3416
|
+
'A candidate that followed a brief withholding the animation name reads `count` 1/1 and 0.000',
|
|
3417
|
+
'on every other name-matched measure — including `duration` and `key_counts` it may have got',
|
|
3418
|
+
'exactly right — so read the pair and not the section mean.',
|
|
3419
|
+
],
|
|
3271
3420
|
},
|
|
3272
3421
|
{
|
|
3273
3422
|
name: 'check',
|
|
@@ -3562,7 +3711,7 @@ try {
|
|
|
3562
3711
|
else if (command === 'ingest') cmdIngest(flags, positional);
|
|
3563
3712
|
else if (command === 'validate') cmdValidate(flags, positional);
|
|
3564
3713
|
else if (command === 'explain') cmdExplain(flags);
|
|
3565
|
-
else if (command === 'diff') cmdDiff(flags, positional);
|
|
3714
|
+
else if (command === 'diff') cmdDiff(flags, lists, positional);
|
|
3566
3715
|
else if (command === 'check') cmdCheck(flags);
|
|
3567
3716
|
else if (command === 'bench') cmdBench(flags, positional);
|
|
3568
3717
|
else if (command === 'bonedist') cmdBoneDist(flags);
|
package/docs/AUTHORING.md
CHANGED
|
@@ -178,7 +178,7 @@ What the flags mean:
|
|
|
178
178
|
| `--again` | `vote --record` only: record a second vote on a ballot the ledger already has. Without it a repeat is refused by name rather than doubled |
|
|
179
179
|
| `--frame` | `pose` and `chainfit`: one pose frame — the picture to read part placements out of. One frame per call; several key poses are several calls, and correlating them is yours (§11) |
|
|
180
180
|
| `--scale` | the scale window to search, as **frame pixels per part pixel**, `<min>,<max>` (default `0.5,2`). The report states what it searched, and a window that does not contain the truth does not reliably refuse — §11. For `chainfit` it sizes the **internal anchor pass** and is refused beside `--anchor` — §12.4 |
|
|
181
|
-
| `--rotation` | the rotation window to search, in screen degrees, `<min>,<max>` (default `-180,180`, a full turn). Narrow it when you know the art is upright. For `chainfit`, again the internal anchor pass — the chains' own window is `--hinge` |
|
|
181
|
+
| `--rotation` | the rotation window to search, in screen degrees, `<min>,<max>` (default `-180,180`, a full turn). Narrow it when you know the art is upright — a window narrower than the coarse step is divided rather than refused, and `search` states the step that division produced (§11.3). For `chainfit`, again the internal anchor pass — the chains' own window is `--hinge` |
|
|
182
182
|
| `--max-residual` | `pose` and `chainfit`: above this residual a placement is **refused by name** instead of reported flat (default `0.25`). A reporting threshold, not a pass bar — the placement is still in the JSON |
|
|
183
183
|
| `--anchor` | `chainfit` only: a `rigc pose` report for **this** frame, whose confident placements become the anchors the chains hang off. Without it that pass runs internally — §12.2 |
|
|
184
184
|
| `--hinge` | `chainfit` only: the window each child bone's local rotation is searched over, in **Spine** degrees about its setup value, `<min>,<max>` (default `-180,180`) — §12.4 |
|
|
@@ -389,6 +389,37 @@ silhouette and an authored mesh's fit figure is the same number. The one thing
|
|
|
389
389
|
that does not change is what rigc **writes**: its own packer never turns a
|
|
390
390
|
region.
|
|
391
391
|
|
|
392
|
+
🔑 **That is a claim about rigc's instruments, and you may be holding only the
|
|
393
|
+
pack.** An author who has to cut a part out of the page by hand — to measure it
|
|
394
|
+
against a rendered frame, or to look at it at all — needs what the `rotate:` line
|
|
395
|
+
means for the numbers beside it, and the pack states none of this:
|
|
396
|
+
|
|
397
|
+
- `bounds: x, y, w, h` gives `w x h` in the **drawing's** orientation, so it is
|
|
398
|
+
the part's size **unturned**: the same two numbers the same drawing carries at
|
|
399
|
+
`rotate: 0`;
|
|
400
|
+
- the rectangle on the **page** is therefore `h x w` at `x, y` — the transpose —
|
|
401
|
+
at `rotate: 90` and at `rotate: 270` alike, while `w x h` is what a region
|
|
402
|
+
occupies at `rotate: 0` and at `rotate: 180`;
|
|
403
|
+
- cut that rectangle out of the page and turn it **clockwise** to recover the
|
|
404
|
+
drawing at `rotate: 90`, and **counter-clockwise** at `rotate: 270`; a half
|
|
405
|
+
turn has no direction to name.
|
|
406
|
+
|
|
407
|
+
Each direction there is **measured** rather than reasoned about: it comes off
|
|
408
|
+
`MeshAttachment.computeUVs` in the linked runtime, the one routine there that
|
|
409
|
+
says where a region's texels are for all four values, and the selftest derives
|
|
410
|
+
the words in that list from the same routine instead of reading them. ⚠️ Do not
|
|
411
|
+
take the turn from `TextureAtlas`'s own `u2`/`v2` — those transpose at a quarter
|
|
412
|
+
turn one way and not at the other, so one of the two pairs describes a rectangle
|
|
413
|
+
the page does not have ([#579](https://github.com/firejune/rigc/issues/579)).
|
|
414
|
+
|
|
415
|
+
`build` now prints the page rectangle on the line for a turned region, so the
|
|
416
|
+
report carries the rectangle to cut instead of leaving it to be derived
|
|
417
|
+
([#718](https://github.com/firejune/rigc/issues/718)):
|
|
418
|
+
|
|
419
|
+
```bash
|
|
420
|
+
# .. pendulum 105x139 <- ../export/atlas.png @ 710,16 rotate 90, occupies 139x105
|
|
421
|
+
```
|
|
422
|
+
|
|
392
423
|
⚠️ Two limits here are real and neither is about rotation:
|
|
393
424
|
|
|
394
425
|
- `--atlas-in` cannot recover what a `scale:` quantised away (above), turned or not;
|
|
@@ -533,7 +564,7 @@ The other commands:
|
|
|
533
564
|
```bash
|
|
534
565
|
bun cli.ts explain --rig … --motion … --out … # the compiled rig as a table
|
|
535
566
|
bun cli.ts validate path/to/spine # re-gate artifacts already on disk
|
|
536
|
-
bun cli.ts diff candidate.json reference.json
|
|
567
|
+
bun cli.ts diff candidate.json reference.json [--as <candidate>=<reference>]…
|
|
537
568
|
bun cli.ts check --candidate path/to/spine --frames path/to/frames [--skin …]
|
|
538
569
|
bun cli.ts bench 3 --candidate path/to/spine [--frames path/to/frames]
|
|
539
570
|
bun cli.ts render --candidate path/to/spine [--animation …] [--skin …] [--fps 12] [--max 256]
|
|
@@ -567,6 +598,19 @@ bun cli.ts pose --images path/to/parts --frame poseA.png [--out pose.json]
|
|
|
567
598
|
deliberately does not combine them into a score: a rig with the right skeleton
|
|
568
599
|
and the wrong timing and a rig with the right timing and the wrong skeleton call
|
|
569
600
|
for opposite fixes. A measure with nothing to compare says `0/0` and says so.
|
|
601
|
+
|
|
602
|
+
⭐ **If your animation is not called what the reference's is, say so with
|
|
603
|
+
`--as <candidate>=<reference>`** (repeatable, one pair each) — or let it pair by
|
|
604
|
+
position, which happens with no flag when each side carries exactly one
|
|
605
|
+
animation. Without a pairing every animation measure but `count` is keyed on the
|
|
606
|
+
name, so a rig whose shot is right down to the key counts reads **0.000** across
|
|
607
|
+
the section. The pairing gets `animations` the same two figures `bones` and
|
|
608
|
+
`slots` carry, and reading them as a pair — name-agnostic 1.000 beside `names`
|
|
609
|
+
0.000 — is what says the shot is right and the name is yours. The measures, the
|
|
610
|
+
refusals and what makes the block absent instead of guessed:
|
|
611
|
+
[INGEST.md](INGEST.md) §1.3.1, and *The measure inventory* in
|
|
612
|
+
[BENCHMARK.md](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md),
|
|
613
|
+
which is repository material rather than part of this package.
|
|
570
614
|
- **`check`** renders your candidate into the reference frames' own pixel grid and
|
|
571
615
|
compares pixels — the only thing here that can see a wrong animation. **§9.**
|
|
572
616
|
🚨 What it certifies is the **default skin** unless you pass `--skin <name>`:
|
|
@@ -1081,6 +1125,22 @@ loader's sentence and all the report had. Since
|
|
|
1081
1125
|
`A08_REGION_NAMES_MATCH_ATTACHMENTS`, with the skin, the slot, the placeholder
|
|
1082
1126
|
and the attachment's own name beside the path.
|
|
1083
1127
|
|
|
1128
|
+
Where each vertex sits on the **art**:
|
|
1129
|
+
|
|
1130
|
+
| Field | Meaning |
|
|
1131
|
+
| --- | --- |
|
|
1132
|
+
| `uvs` | one `u, v` per vertex, in the order the geometry below uses. Both are fractions of the region rather than pixels, and the array's length is what fixes the vertex count — a mesh states no `vertexCount`. **`(0, 0)` is the region's top-left pixel and `(1, 1)` its bottom-right**, so `u` grows toward the right edge and `v` grows *downward* toward the bottom edge: the crop-pixel convention §11.2 states for a manifest, and not Spine's y-up world. What a **turned** region (`rotate: 90`, in a pack somebody else made) does to that is §0.2's subject |
|
|
1133
|
+
|
|
1134
|
+
⭐ **Measured, because an assumption about a corner is invisible in a build that
|
|
1135
|
+
went green.** On a 64×48 plate whose four quadrants are four flat colours,
|
|
1136
|
+
`rigc render` drew a quad given `uvs` running `0 → 0.5` on both axes as 3,120 px
|
|
1137
|
+
of the **top-left** quadrant's colour and not one pixel of any other, and the same
|
|
1138
|
+
quad given `0.5 → 1` as 3,120 px of the **bottom-right**. Beside it, the runtime's own
|
|
1139
|
+
`MeshAttachment.computeUVs`, handed the emitted atlas region, put `(0, 0)` at page
|
|
1140
|
+
pixel `(0.00, 0.00)` and `(1, 1)` at `(64.00, 48.00)` — the region's own two
|
|
1141
|
+
corners, in the page's y-down pixels. `CUR44` in the selftest holds the sentence
|
|
1142
|
+
above against that second measurement.
|
|
1143
|
+
|
|
1084
1144
|
Geometry comes in one of two fields:
|
|
1085
1145
|
|
|
1086
1146
|
| Field | Meaning |
|
|
@@ -1267,6 +1327,105 @@ and uvs. `A13_MESH_BUDGET` counts it as a mesh of its own: the runtime draws it
|
|
|
1267
1327
|
one, so a link in a second slot is a second mesh slot against
|
|
1268
1328
|
`invariants.meshSlots`.
|
|
1269
1329
|
|
|
1330
|
+
**The three types that carry geometry and no art** — a bounding box, a clipping
|
|
1331
|
+
polygon and a path. None of them resolves an atlas region, so none of them has an
|
|
1332
|
+
`image`, a `path` key, a `width`/`height` or uvs, and a skin holding only these
|
|
1333
|
+
builds an atlas with **no pages** (above). What they do share is one geometry
|
|
1334
|
+
shape, and it is the mesh's with one field added:
|
|
1335
|
+
|
|
1336
|
+
- 🚨 **`vertexCount` is required and has no parser default.** A mesh takes its
|
|
1337
|
+
count from `uvs.length`; these have no uvs, so the parser reads
|
|
1338
|
+
`map.vertexCount << 1` as the length to expect — and with the field absent that
|
|
1339
|
+
is `undefined << 1` = **0**, which sends `readVertices` down the WEIGHTED branch,
|
|
1340
|
+
decodes the coordinate list as a weight run, and hands back an attachment with no
|
|
1341
|
+
vertices at all. Nothing throws, and none of the three draws a pixel, so nothing
|
|
1342
|
+
downstream notices. rigc refuses it by name: `vertexCount is undefined; a polygon
|
|
1343
|
+
needs at least 3 vertices, stated outright`.
|
|
1344
|
+
- **The two encodings are the mesh's own**, traps included: `weights` binds bones
|
|
1345
|
+
by NAME and is the form to use; `vertices` is an unweighted `x, y` run when
|
|
1346
|
+
`vertices.length === vertexCount * 2` and Spine's index-encoded weighted run
|
|
1347
|
+
otherwise, and the second of those needs `"boneIndexing": "raw"` said out loud.
|
|
1348
|
+
`A33_VERTEX_ATTACHMENT_GEOMETRY` (§5.2) gates all three types and accepts either
|
|
1349
|
+
encoding on each — measured by building both on each of the three.
|
|
1350
|
+
- 📐 **Which space the numbers are in.** An unweighted `x, y` is in the **slot's
|
|
1351
|
+
bone's** local space; a `weights` binding's `x`/`y` is in **that binding's own
|
|
1352
|
+
bone's** local space, and the slot's bone plays no part in it. Measured through
|
|
1353
|
+
spine-core on a rig where the two are different bones: `(0, 0)`, `(30, 0)`,
|
|
1354
|
+
`(30, 20)` written unweighted on a slot whose bone sits at `(-60, 25)` turned
|
|
1355
|
+
−21° posed at `(-60.0000, 25.0000)`, `(-31.9926, 14.2490)` and
|
|
1356
|
+
`(-24.8252, 32.9206)`, which is that bone's own transform of them; the same three
|
|
1357
|
+
points bound by name to a bone at `(110, 70)` turned 37° and scaled `1.3, 0.8`,
|
|
1358
|
+
in the very same slot, posed at `(110.0000, 70.0000)`, `(141.1468, 93.4708)` and
|
|
1359
|
+
`(131.5177, 106.2490)` — the BOUND bone's transform, to four decimals.
|
|
1360
|
+
- **A `deform` timeline reaches all three** (§4.11); none of them is drawn, so a
|
|
1361
|
+
render can show you nothing about any of them.
|
|
1362
|
+
|
|
1363
|
+
**Bounding box** ([Spine: bounding boxes](http://esotericsoftware.com/spine-bounding-boxes)) —
|
|
1364
|
+
`"type": "boundingbox"`. A polygon the game hit-tests against — a hurt box, a pick
|
|
1365
|
+
region, a trigger volume — that moves with the skeleton and draws nothing.
|
|
1366
|
+
|
|
1367
|
+
| Field | Meaning |
|
|
1368
|
+
| --- | --- |
|
|
1369
|
+
| `type` | `"boundingbox"`. **Required**: an omitted `type` is `"region"`, and a region has nowhere to put these keys — measured, the refusal reads `attachment "mask" (region) has 2 keys this compiler does not read: "vertexCount", "vertices"` |
|
|
1370
|
+
| `vertexCount` | **required**, 3 or more. No default — the paragraph above is why |
|
|
1371
|
+
| `vertices` | the unweighted `x, y` run, in the slot bone's local space; or the index-encoded weighted run, behind `boneIndexing` |
|
|
1372
|
+
| `weights` | the by-name form: one entry per vertex, each a list of `{ "bone": …, "x": …, "y": …, "weight": … }`, each pair in that bone's local space. Never beside `vertices` |
|
|
1373
|
+
| `boneIndexing` | `"name"` (the default) or `"raw"`, which opts a weighted `vertices` run into Spine's index encoding. `"raw"` beside `weights` is refused: `weights` always binds by name |
|
|
1374
|
+
| `color` | `rrggbbaa`. **No default in the file** — omitted, the parser never calls `setFromString` and the attachment keeps the runtime's own colour. It is an editor affordance, the colour the polygon is drawn in there; rigc emits it verbatim when stated and leaves the key out when not |
|
|
1375
|
+
|
|
1376
|
+
**Clipping polygon** ([Spine: clipping](http://esotericsoftware.com/spine-clipping)) —
|
|
1377
|
+
`"type": "clipping"`. A mask: the polygon clips every slot drawn from the one
|
|
1378
|
+
carrying it up to and including `end`, so a window, a portal or a wipe is one
|
|
1379
|
+
attachment rather than a second set of art.
|
|
1380
|
+
|
|
1381
|
+
| Field | Meaning |
|
|
1382
|
+
| --- | --- |
|
|
1383
|
+
| `type` | `"clipping"`. **Required** |
|
|
1384
|
+
| `vertexCount`, `vertices`, `weights`, `boneIndexing`, `color` | exactly as on a bounding box |
|
|
1385
|
+
| `end` | the last slot the clip applies to, **by name**. Absent is the parser's own encoding for *clip everything after this one*, which is why a typo cannot be told from an omission once the file is loaded: `findSlot` returns null on a miss and the parser assigns that null without a word, so the clip runs to the bottom of the draw order and takes every slot below it with it. rigc refuses a name the rig does not declare — `end names slot "X", which this rig does not declare` — and `A33` refuses it again on a skeleton rigc did not write |
|
|
1386
|
+
| `convex` | default **false**. True tells the runtime the polygon is convex so it can clip without triangulating it, and a polygon that deforms concave is clipped by its convex hull instead (`ClippingAttachment.convex`). Nothing here checks that the polygon is in fact convex |
|
|
1387
|
+
| `inverse` | default **false**. True makes everything **outside** the polygon visible instead of everything inside, and inverse clipping is always treated as convex (`ClippingAttachment.inverse`) |
|
|
1388
|
+
|
|
1389
|
+
⚠️ **A clipping attachment is refused by the renderer profile and by nothing
|
|
1390
|
+
else.** `A11_NO_CLIPPING_ATTACHMENTS` (§5.2) fires under `--profile spine-html`
|
|
1391
|
+
because that renderer skips clipping silently; it is one renderer's policy rather
|
|
1392
|
+
than anything about the data, and the default `spine` profile builds one.
|
|
1393
|
+
|
|
1394
|
+
**Path** ([Spine: paths](http://esotericsoftware.com/spine-paths)) —
|
|
1395
|
+
`"type": "path"`. The composite cubic Bezier a path **constraint** (§3.5.1) slides
|
|
1396
|
+
bones along. No runtime draws it, and it deforms with the slot's bone like any
|
|
1397
|
+
other vertex attachment.
|
|
1398
|
+
|
|
1399
|
+
| Field | Meaning |
|
|
1400
|
+
| --- | --- |
|
|
1401
|
+
| `type` | `"path"`. **Required** |
|
|
1402
|
+
| `vertexCount`, `vertices`, `weights`, `boneIndexing`, `color` | as on a bounding box — except that these vertices are knots **and** their handles, which the count rule below is about |
|
|
1403
|
+
| `closed` | default **false**. True joins the last knot back to the first |
|
|
1404
|
+
| `constantSpeed` | default **true** — note the direction. Leaving it out asks for the expensive-and-correct traversal, in which the runtime re-measures the path every frame and `lengths` is never read. `false` makes the runtime trust the emitted `lengths` instead: cheaper, exact only while the path holds its setup shape, and the reason a deformed path wants the default |
|
|
1405
|
+
| `lengths` | 🚫 **refused by name.** rigc measures the setup length of each curve off the geometry and emits it, the way it measures a region's size off its PNG: `"lengths" is not authored — rigc measures the setup arc length of each curve…`. The field is declared only so the refusal can say that rather than report a misspelt key. What the numbers are — and why *arc length* is the wrong name for them — is §10.6 |
|
|
1406
|
+
|
|
1407
|
+
🚨 **`vertexCount` counts knots AND handles, and it has to be a multiple of 3.**
|
|
1408
|
+
The parser hands `vertexCount << 1` to `readVertices` and then walks the result in
|
|
1409
|
+
groups of six: the first and last points are the outer control handles of the end
|
|
1410
|
+
knots and are dropped, leaving a `3K + 1` chain. So an OPEN path of K curves states
|
|
1411
|
+
`vertexCount = 3(K + 1)`, minimum **6**, and a CLOSED one states `3K`, minimum
|
|
1412
|
+
**3**. A count that is not a multiple of 3 does not throw —
|
|
1413
|
+
`Utils.newArray(vertexCount / 3, 0)` accepts a fractional size, the groups of six
|
|
1414
|
+
then straddle the knots, and the constraint slides bones along a curve nobody drew
|
|
1415
|
+
— so rigc refuses both shapes: `vertexCount is N, which is not a multiple of 3`
|
|
1416
|
+
and `vertexCount is N and an open path needs at least 6`.
|
|
1417
|
+
|
|
1418
|
+
```json
|
|
1419
|
+
"track": { "track": { "type": "path", "vertexCount": 9,
|
|
1420
|
+
"vertices": [-30, 0, 0, 0, 30, 0, 60, 0, 90, 0, 120, 0, 150, 0, 180, 0, 210, 0] } }
|
|
1421
|
+
```
|
|
1422
|
+
|
|
1423
|
+
Nine points are two curves. The outer handles at `x = -30` and `x = 210` are
|
|
1424
|
+
dropped, so the chain runs from `x = 0` to `x = 180` and rigc emits
|
|
1425
|
+
`"lengths": [90, 180]` beside it — measured, not stated. That is the path
|
|
1426
|
+
§3.5.1's constraint example rides: with `position: 0.25` its `cart` bone poses at
|
|
1427
|
+
`worldX = 45.000000`.
|
|
1428
|
+
|
|
1270
1429
|
The generators are `ring`, `ribbon`, `contour` and `grid` (see
|
|
1271
1430
|
[`src/mesh.ts`](../src/mesh.ts)); the first two encode a deformation model rather
|
|
1272
1431
|
than a table of numbers, which is why they are code invoked by data. The last two
|
|
@@ -7331,7 +7490,7 @@ Per part:
|
|
|
7331
7490
|
| `ambiguous` | at least one alternate is inside the ambiguity margin. **Choose with something this instrument cannot see** — anatomy, the other frame, or `rigc vote` |
|
|
7332
7491
|
| `rotationFree` | the part is self-similar under rotation, so `rotationDeg` is a placeholder and the value is yours |
|
|
7333
7492
|
| `rotationSelfSimilarity` | the number `rotationFree` is a threshold on. A part just over the line is worth a look |
|
|
7334
|
-
| `refusal` | `{ reason, detail }` or `null`. Reasons: `no-match`, `larger-than-canvas`, `empty-part` |
|
|
7493
|
+
| `refusal` | `{ reason, detail }` or `null`. Reasons: `no-match`, `larger-than-canvas`, `empty-part`. A `no-match` whose best placement stopped **on a wall of the search window** names the wall in its detail — see §11.4 |
|
|
7335
7494
|
| `coarse` | the grid this part was actually searched on. A handful of cells means the part is small relative to the frame and the first pass had little to go on |
|
|
7336
7495
|
| `notes` | the same facts in prose, in the order they were found |
|
|
7337
7496
|
|
|
@@ -7349,6 +7508,16 @@ The report also carries `frame.background` (how the picture's empty space was
|
|
|
7349
7508
|
identified — a flat colour, transparency, or `unknown`), `search` (every window
|
|
7350
7509
|
and threshold that was applied), and `caveats`.
|
|
7351
7510
|
|
|
7511
|
+
🔒 **`search` states what ran, not what was asked for.** `search.rotation` carries
|
|
7512
|
+
`degrees`, the ladder the coarse pass actually walked, and its `stepDeg` is read
|
|
7513
|
+
off that ladder rather than off the constant the ladder was capped at. The coarse
|
|
7514
|
+
step `15°` is a **ceiling** on the step, not the step: a narrower window is
|
|
7515
|
+
divided into whole steps no coarser than it, so
|
|
7516
|
+
`--rotation -5,5` prints `rotation -5°–5° in 2 step(s) of 10°` and walks those two
|
|
7517
|
+
angles and no others. Nothing reported leaves the window either — the refinement
|
|
7518
|
+
and the quarter-turn probes are held inside it, and a window spanning a full turn
|
|
7519
|
+
holds nothing because it already contains every angle.
|
|
7520
|
+
|
|
7352
7521
|
### 11.4 What it cannot see — read this before using the numbers
|
|
7353
7522
|
|
|
7354
7523
|
- ⚠️ **Residuals degrade under occlusion, and there is no depth solver here.** A
|
|
@@ -7364,6 +7533,16 @@ and threshold that was applied), and `caveats`.
|
|
|
7364
7533
|
answer is the best placement available *inside* `--scale` / `--rotation` and its
|
|
7365
7534
|
residual can look reasonable. This is why the window is a reported field: if the
|
|
7366
7535
|
numbers surprise you, check `search` before you trust them.
|
|
7536
|
+
- 🔒 **A refusal that stopped on a wall of the window says which wall.** When a
|
|
7537
|
+
`no-match`'s best placement sits on the floor or the ceiling of `--scale`, or on
|
|
7538
|
+
an edge of a `--rotation` window narrower than a full turn, the detail names it:
|
|
7539
|
+
`best placement at scale 0.500, the floor of --scale 0.5,2 — the truth may lie below the window`.
|
|
7540
|
+
That is the case where the window is the first thing to move rather than the
|
|
7541
|
+
frame or the threshold — eleven parts of one frame came back refused at
|
|
7542
|
+
`scale=0.500` against art rendered at `0.311` per part pixel, and the message
|
|
7543
|
+
said only that the residual was above `--max-residual`. It is printed on a
|
|
7544
|
+
**refusal and nowhere else**: an accepted placement sitting on a wall is a window
|
|
7545
|
+
chosen to bracket the answer, which is the flag working.
|
|
7367
7546
|
- ⚠️ **A frame whose border has no dominant colour reports `background.unknown`.**
|
|
7368
7547
|
Every pixel then counts as material, the silhouette signal is gone, and the
|
|
7369
7548
|
residual is colour agreement alone. The report says so rather than being quietly
|
package/docs/INGEST.md
CHANGED
|
@@ -237,7 +237,9 @@ Spine runtime plays it, whatever rigc's own rasteriser or validator thinks.
|
|
|
237
237
|
`diff` takes two compiled skeletons and reports 49 measures in eight groups, plus two
|
|
238
238
|
blocks that report and gate nothing: the `(reported)` measures beside `attachments`
|
|
239
239
|
and `animations`, and the `skeleton` header block at the top, which measures the stage
|
|
240
|
-
(issue #578).
|
|
240
|
+
(issue #578). A ninth group of six joins them when something has paired the two sides'
|
|
241
|
+
animations — `--as <candidate>=<reference>`, or one animation each side, which pairs by
|
|
242
|
+
position (§1.3.1). Both sides may be foreign; the interesting pairing during ingest is
|
|
241
243
|
**your transcription against the export it came from**:
|
|
242
244
|
|
|
243
245
|
```bash
|
|
@@ -326,6 +328,44 @@ attachment types and bone-binding shapes by draw-order position. That is how you
|
|
|
326
328
|
*"the same rig with a different vocabulary"* from *"a different rig"*, and §4.2 is the
|
|
327
329
|
recipe built on it.
|
|
328
330
|
|
|
331
|
+
#### 1.3.1 Animations differ by name too — `--as`
|
|
332
|
+
|
|
333
|
+
`bones` and `slots` are matched name-agnostically by their own shape. Animations have
|
|
334
|
+
none: the candidate's `take01` and the reference's `arcs` are the same shot only
|
|
335
|
+
because somebody says they are. Two things say it —
|
|
336
|
+
|
|
337
|
+
```bash
|
|
338
|
+
rigc diff work/t6/skeleton.json examples/6-arcs/export/6-arcs-pro.json --as take01=arcs
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
— and, with no flag, **one animation each side**, which pairs by position because there
|
|
342
|
+
is exactly one reading of which shot is which. `--as` is repeatable, one pair each, and
|
|
343
|
+
the candidate's name goes on the left, as it does in `bonedist`'s correspondence file.
|
|
344
|
+
|
|
345
|
+
With the pairing in hand the `animations` section reports two figures like the other
|
|
346
|
+
two sections, the second over the paired shots — `duration`, `timeline_kinds`,
|
|
347
|
+
`key_counts`, `curve_kinds`, `draw_order`, `deform` — and the heading says which pairing
|
|
348
|
+
it used:
|
|
349
|
+
|
|
350
|
+
```
|
|
351
|
+
animations mean 0.222 over 9 measures
|
|
352
|
+
1.000 count 1/1 how many animations
|
|
353
|
+
0.000 names 0/2 the animation names
|
|
354
|
+
…
|
|
355
|
+
animations (name-agnostic) mean 1.000 over 6 measures — the same two skeletons compared with names thrown away, paired by position: take01=arcs, the one animation each side carries
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
Read that pair exactly as you read `bones`'s: **1.000 beside `names` 0.000** says the
|
|
359
|
+
shot is right and its name is yours. ⛔ `names` never moves into the second block, and
|
|
360
|
+
with two shots on each side and no `--as`, the block is **absent** rather than paired by
|
|
361
|
+
declaration order — a candidate that declares its two shots the other way round would
|
|
362
|
+
then read 0.000 across it and the report would be calling a guess a measurement.
|
|
363
|
+
|
|
364
|
+
⚠️ An `--as` naming an animation a side does not have is **refused** with what that side
|
|
365
|
+
does have, and so is one that pairs the same animation twice. Neither is dropped
|
|
366
|
+
quietly: a typo that measured less than you asked for is a report about a pairing you
|
|
367
|
+
did not state.
|
|
368
|
+
|
|
329
369
|
### 1.4 `check` — the instrument that does see coordinates
|
|
330
370
|
|
|
331
371
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Rig compiler for Spine — declarative rig specs in, Spine 4.3 skeleton data out, verified by a spine-core round-trip. Built so AI agents can author rigs and check their own work; the output imports into the Spine editor.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/diff.ts
CHANGED
|
@@ -46,6 +46,34 @@
|
|
|
46
46
|
* wrong; name-agnostic low alone is impossible, since a wrong shape cannot
|
|
47
47
|
* have right names.
|
|
48
48
|
*
|
|
49
|
+
* ⭐ `animations` carries the same second comparison, and it arrived last
|
|
50
|
+
* because it needs something the other two do not: a PAIRING. A bone is
|
|
51
|
+
* paired with a bone by its depth and its child count, which the file
|
|
52
|
+
* states; two animations have no such shape to be matched on, so the
|
|
53
|
+
* candidate's `take01` and the reference's `arcs` are the same shot only
|
|
54
|
+
* because somebody says they are. Until that was said, every animation
|
|
55
|
+
* measure was keyed on the name — and a candidate that followed a brief
|
|
56
|
+
* withholding it read `count` 1/1 and **0.000 on all eight measures below**,
|
|
57
|
+
* on a shot with the same duration, the same timeline families and the same
|
|
58
|
+
* key counts (issue #720). That is the *gate that cannot be passed* shape on
|
|
59
|
+
* the measuring instrument rather than on the gate.
|
|
60
|
+
*
|
|
61
|
+
* Two things say it, and nothing else does. `--as <candidate>=<reference>`
|
|
62
|
+
* pairs them outright; failing that, **one animation each side** pairs by
|
|
63
|
+
* position, because there is exactly one reading of which shot is which and
|
|
64
|
+
* no name is consulted to reach it. Anything else — two against two, three
|
|
65
|
+
* against one — has several readings, so the block is ABSENT rather than
|
|
66
|
+
* guessed at, which is what `DiffSection.nameAgnostic` means by *"should say
|
|
67
|
+
* so by having none"*. ⚠️ Guessing there is the failure this file is built
|
|
68
|
+
* against: pairing two-against-two by position would score a candidate whose
|
|
69
|
+
* two shots are declared in the other order 0.000 across the block and call
|
|
70
|
+
* it a measurement.
|
|
71
|
+
*
|
|
72
|
+
* 🔒 `names` stays in the name-matched block alone, and that is the whole
|
|
73
|
+
* point of the split rather than an oversight: the pair is read as *agnostic
|
|
74
|
+
* 1.000 with `names` 0.000*, which says the shot is right and its name is the
|
|
75
|
+
* author's own.
|
|
76
|
+
*
|
|
49
77
|
* 4. **A measure that cannot gate is not in the mean.** `section.reported`
|
|
50
78
|
* carries the measures `docs/GATE.md`'s *What never gates* calls
|
|
51
79
|
* unobservable by construction — *"could any reading of the frames have
|
|
@@ -121,6 +149,18 @@ export interface DiffAgnostic {
|
|
|
121
149
|
/** Unweighted mean of the measures below. NOT a quality score either. */
|
|
122
150
|
ratio: number;
|
|
123
151
|
measures: DiffMeasure[];
|
|
152
|
+
/**
|
|
153
|
+
* How the two sides were put against each other, for a block that had to
|
|
154
|
+
* choose — `animations` alone today. Absent where the correspondence is the
|
|
155
|
+
* elements themselves and there was nothing to decide.
|
|
156
|
+
*
|
|
157
|
+
* ⚠️ It is data rather than a caption. A block whose figures depend on a
|
|
158
|
+
* pairing, printed without the pairing beside it, is a measurement of
|
|
159
|
+
* something the reader cannot name — and the two pairings say different
|
|
160
|
+
* things: `--as` is the caller's claim, position is this file's reading of a
|
|
161
|
+
* one-against-one roster.
|
|
162
|
+
*/
|
|
163
|
+
pairedBy?: string;
|
|
124
164
|
}
|
|
125
165
|
|
|
126
166
|
/**
|
|
@@ -279,12 +319,21 @@ function sectionOf(
|
|
|
279
319
|
measures: DiffMeasure[],
|
|
280
320
|
nameAgnostic?: DiffMeasure[],
|
|
281
321
|
reported?: DiffMeasure[],
|
|
322
|
+
pairedBy?: string,
|
|
282
323
|
): DiffSection {
|
|
283
324
|
return {
|
|
284
325
|
name,
|
|
285
326
|
ratio: meanRatio(measures),
|
|
286
327
|
measures,
|
|
287
|
-
...(nameAgnostic === undefined
|
|
328
|
+
...(nameAgnostic === undefined
|
|
329
|
+
? {}
|
|
330
|
+
: {
|
|
331
|
+
nameAgnostic: {
|
|
332
|
+
ratio: meanRatio(nameAgnostic),
|
|
333
|
+
measures: nameAgnostic,
|
|
334
|
+
...(pairedBy === undefined ? {} : { pairedBy }),
|
|
335
|
+
},
|
|
336
|
+
}),
|
|
288
337
|
...(reported === undefined ? {} : { reported: { measures: reported } }),
|
|
289
338
|
};
|
|
290
339
|
}
|
|
@@ -905,11 +954,154 @@ function keyingTotals(f: AnimationFacts): KeyingTotals {
|
|
|
905
954
|
return { keys: sum(f.keys), timelines: sum(f.kinds), seconds: sum(f.duration) };
|
|
906
955
|
}
|
|
907
956
|
|
|
908
|
-
|
|
957
|
+
/** One animation on each side, said to be the same shot. */
|
|
958
|
+
export interface DiffAnimationPair {
|
|
959
|
+
candidate: string;
|
|
960
|
+
reference: string;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* What a caller may tell `diffSkeletons` that neither file can say itself.
|
|
965
|
+
*
|
|
966
|
+
* Only the animation pairing today, and it is an INPUT in the sense
|
|
967
|
+
* `bonedist`'s correspondence file is one: two skeletons cannot derive which of
|
|
968
|
+
* their shots are the same shot, so a value worked out here would be a guess
|
|
969
|
+
* reported as a measurement.
|
|
970
|
+
*/
|
|
971
|
+
export interface DiffOptions {
|
|
972
|
+
/** `--as <candidate>=<reference>`, in the order the caller stated them. */
|
|
973
|
+
animationPairs?: readonly DiffAnimationPair[];
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* The pairing used for `animations.agnostic.*`, or `null` when there is none.
|
|
978
|
+
*
|
|
979
|
+
* ⚠️ A stated pair naming an animation a side does not have is DROPPED and said
|
|
980
|
+
* so in `pairedBy`, rather than silently making the block narrower. `cmdDiff`
|
|
981
|
+
* refuses one by name before this is reached, so through the CLI the branch is
|
|
982
|
+
* unreachable; it exists because this module is exported and a caller of the
|
|
983
|
+
* API can state one.
|
|
984
|
+
*/
|
|
985
|
+
function pairAnimations(
|
|
986
|
+
a: AnimationFacts,
|
|
987
|
+
b: AnimationFacts,
|
|
988
|
+
stated: readonly DiffAnimationPair[],
|
|
989
|
+
): { pairs: DiffAnimationPair[]; pairedBy: string } | null {
|
|
990
|
+
const spell = (p: DiffAnimationPair): string => `${p.candidate}=${p.reference}`;
|
|
991
|
+
if (stated.length > 0) {
|
|
992
|
+
const usable = stated.filter((p) => a.duration.has(p.candidate) && b.duration.has(p.reference));
|
|
993
|
+
if (usable.length === 0) return null;
|
|
994
|
+
const dropped = stated.filter((p) => !usable.includes(p));
|
|
995
|
+
return {
|
|
996
|
+
pairs: [...usable],
|
|
997
|
+
pairedBy:
|
|
998
|
+
`paired by --as: ${usable.map(spell).join(', ')}` +
|
|
999
|
+
(dropped.length === 0 ? '' : `; ${dropped.map(spell).join(', ')} named an animation a side does not have and was dropped`),
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
if (a.names.length === 1 && b.names.length === 1) {
|
|
1003
|
+
const pair = { candidate: a.names[0], reference: b.names[0] };
|
|
1004
|
+
return { pairs: [pair], pairedBy: `paired by position: ${spell(pair)}, the one animation each side carries` };
|
|
1005
|
+
}
|
|
1006
|
+
return null;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* `f` restricted to the animations `label` names, with each one's name replaced
|
|
1011
|
+
* by the label — `#0` for the first pair, `#1` for the second.
|
|
1012
|
+
*
|
|
1013
|
+
* That substitution is the whole of what makes the block name-agnostic: the
|
|
1014
|
+
* measures below are the name-matched ones run again over facts whose keys are
|
|
1015
|
+
* positions in the pairing. Everything else about them — the tolerance, the
|
|
1016
|
+
* denominators, the histogram — is unchanged, which is what lets the two blocks
|
|
1017
|
+
* be read against each other.
|
|
1018
|
+
*/
|
|
1019
|
+
function underLabels(f: AnimationFacts, label: ReadonlyMap<string, string>): AnimationFacts {
|
|
1020
|
+
const keyed = <T>(m: Map<string, T>): Map<string, T> => {
|
|
1021
|
+
const out = new Map<string, T>();
|
|
1022
|
+
for (const [anim, v] of m) {
|
|
1023
|
+
const to = label.get(anim);
|
|
1024
|
+
if (to !== undefined) out.set(to, v);
|
|
1025
|
+
}
|
|
1026
|
+
return out;
|
|
1027
|
+
};
|
|
1028
|
+
// `kinds`, `keys` and `curves` are keyed `<anim>|<rest>`, so only the head is
|
|
1029
|
+
// relabelled and the tail — the timeline's kind, the curve's shape — is what
|
|
1030
|
+
// the histogram then intersects on.
|
|
1031
|
+
const prefixed = (m: Map<string, number>): Map<string, number> => {
|
|
1032
|
+
const out = new Map<string, number>();
|
|
1033
|
+
for (const [k, v] of m) {
|
|
1034
|
+
const bar = k.indexOf('|');
|
|
1035
|
+
const to = label.get(k.slice(0, bar));
|
|
1036
|
+
if (to === undefined) continue;
|
|
1037
|
+
const id = `${to}${k.slice(bar)}`;
|
|
1038
|
+
out.set(id, (out.get(id) ?? 0) + v);
|
|
1039
|
+
}
|
|
1040
|
+
return out;
|
|
1041
|
+
};
|
|
1042
|
+
return {
|
|
1043
|
+
names: [...label.values()],
|
|
1044
|
+
duration: keyed(f.duration),
|
|
1045
|
+
kinds: prefixed(f.kinds),
|
|
1046
|
+
keys: prefixed(f.keys),
|
|
1047
|
+
curves: prefixed(f.curves),
|
|
1048
|
+
events: keyed(f.events),
|
|
1049
|
+
hasDrawOrder: keyed(f.hasDrawOrder),
|
|
1050
|
+
hasDeform: keyed(f.hasDeform),
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* The six measures of `animations.agnostic.*`.
|
|
1056
|
+
*
|
|
1057
|
+
* ⛔ `names` is not among them, by construction — a block that threw the names
|
|
1058
|
+
* away cannot then compare them. ⛔ Neither is `count`, which `bones` and
|
|
1059
|
+
* `slots` do carry, and the difference is what the block is OVER: those two
|
|
1060
|
+
* compare whole rosters, so their agnostic half has the same subject as their
|
|
1061
|
+
* name-matched half and restates the count for a reader with one block open.
|
|
1062
|
+
* This one is over the PAIRS. A `count` in it would either restate the roster
|
|
1063
|
+
* figure — a different subject under the same heading — or count the pairs,
|
|
1064
|
+
* which measures the flag rather than the two rigs. The roster figure is
|
|
1065
|
+
* `animations.count`, and it is already name-free.
|
|
1066
|
+
*/
|
|
1067
|
+
function agnosticAnimationMeasures(a: AnimationFacts, b: AnimationFacts, pairs: readonly DiffAnimationPair[]): DiffMeasure[] {
|
|
1068
|
+
const slot = (i: number): string => `#${i}`;
|
|
1069
|
+
const ca = underLabels(a, new Map(pairs.map((p, i) => [p.candidate, slot(i)])));
|
|
1070
|
+
const rb = underLabels(b, new Map(pairs.map((p, i) => [p.reference, slot(i)])));
|
|
1071
|
+
return [
|
|
1072
|
+
agreement(
|
|
1073
|
+
'animations.agnostic.duration',
|
|
1074
|
+
'each paired animation runs as long (last key time, within one frame)',
|
|
1075
|
+
ca.duration,
|
|
1076
|
+
rb.duration,
|
|
1077
|
+
(x, y) => Math.abs(x - y) <= FRAME,
|
|
1078
|
+
),
|
|
1079
|
+
histogram('animations.agnostic.timeline_kinds', 'the same timelines exist in the paired animations', ca.kinds, rb.kinds),
|
|
1080
|
+
histogram('animations.agnostic.key_counts', 'those timelines carry as many keys', ca.keys, rb.keys),
|
|
1081
|
+
histogram('animations.agnostic.curve_kinds', 'as many linear / stepped / bezier keys', ca.curves, rb.curves),
|
|
1082
|
+
agreement(
|
|
1083
|
+
'animations.agnostic.draw_order',
|
|
1084
|
+
'a draw-order timeline is present or absent alike',
|
|
1085
|
+
ca.hasDrawOrder,
|
|
1086
|
+
rb.hasDrawOrder,
|
|
1087
|
+
(x, y) => x === y,
|
|
1088
|
+
),
|
|
1089
|
+
agreement(
|
|
1090
|
+
'animations.agnostic.deform',
|
|
1091
|
+
'a deform timeline is present or absent alike',
|
|
1092
|
+
ca.hasDeform,
|
|
1093
|
+
rb.hasDeform,
|
|
1094
|
+
(x, y) => x === y,
|
|
1095
|
+
),
|
|
1096
|
+
];
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function diffAnimations(c: Json, r: Json, pairsStated: readonly DiffAnimationPair[]): DiffSection {
|
|
909
1100
|
const a = animationFacts(c);
|
|
910
1101
|
const b = animationFacts(r);
|
|
911
1102
|
const at = keyingTotals(a);
|
|
912
1103
|
const bt = keyingTotals(b);
|
|
1104
|
+
const paired = pairAnimations(a, b, pairsStated);
|
|
913
1105
|
const perSecond = (t: KeyingTotals): number => (t.seconds === 0 ? 0 : t.keys / t.seconds);
|
|
914
1106
|
const perTimeline = (t: KeyingTotals): number => (t.timelines === 0 ? 0 : t.keys / t.timelines);
|
|
915
1107
|
return sectionOf('animations', [
|
|
@@ -929,7 +1121,14 @@ function diffAnimations(c: Json, r: Json): DiffSection {
|
|
|
929
1121
|
agreement('animations.draw_order', 'a draw-order timeline is present or absent alike', a.hasDrawOrder, b.hasDrawOrder, (x, y) => x === y),
|
|
930
1122
|
agreement('animations.deform', 'a deform timeline is present or absent alike', a.hasDeform, b.hasDeform, (x, y) => x === y),
|
|
931
1123
|
],
|
|
932
|
-
|
|
1124
|
+
// ── the same two skeletons' shots, paired rather than named (issue #720) ──
|
|
1125
|
+
//
|
|
1126
|
+
// Absent unless something pairs them — see `pairAnimations` and the header's
|
|
1127
|
+
// point 3. `undefined` and not `[]`: a block with no measures in it prints a
|
|
1128
|
+
// vacuous `mean 1.000 over 0 measures`, which is the false green this whole
|
|
1129
|
+
// file is built to refuse, and `movedAgnosticMeasures` cannot tell it from a
|
|
1130
|
+
// block that agreed about everything.
|
|
1131
|
+
paired === null ? undefined : agnosticAnimationMeasures(a, b, paired.pairs),
|
|
933
1132
|
// ── reported (issue #20) ────────────────────────────────────────────────
|
|
934
1133
|
//
|
|
935
1134
|
// 🔍 What #20 asked and what was actually wrong. The issue proposed making key
|
|
@@ -981,7 +1180,8 @@ function diffAnimations(c: Json, r: Json): DiffSection {
|
|
|
981
1180
|
`(${at.timelines} vs ${bt.timelines}), compared as min/max at ${RATE_PLACES} decimal places. Read beside ` +
|
|
982
1181
|
'`key_density`: this one alone moving means the same keying spread over a different number of timelines.',
|
|
983
1182
|
),
|
|
984
|
-
]
|
|
1183
|
+
],
|
|
1184
|
+
paired?.pairedBy);
|
|
985
1185
|
}
|
|
986
1186
|
|
|
987
1187
|
function eventFacts(root: Json): Map<string, string> {
|
|
@@ -1168,11 +1368,19 @@ function orientation(root: Json): Record<string, number> {
|
|
|
1168
1368
|
};
|
|
1169
1369
|
}
|
|
1170
1370
|
|
|
1171
|
-
export function diffSkeletons(candidate: unknown, reference: unknown): DiffReport {
|
|
1371
|
+
export function diffSkeletons(candidate: unknown, reference: unknown, options?: DiffOptions): DiffReport {
|
|
1172
1372
|
const c = isObj(candidate) ? candidate : {};
|
|
1173
1373
|
const r = isObj(reference) ? reference : {};
|
|
1374
|
+
const animationPairs = options?.animationPairs ?? [];
|
|
1174
1375
|
return {
|
|
1175
|
-
sections: [
|
|
1376
|
+
sections: [
|
|
1377
|
+
diffBones(c, r),
|
|
1378
|
+
diffSlots(c, r),
|
|
1379
|
+
diffAttachments(c, r),
|
|
1380
|
+
diffConstraints(c, r),
|
|
1381
|
+
diffAnimations(c, r, animationPairs),
|
|
1382
|
+
diffEvents(c, r),
|
|
1383
|
+
],
|
|
1176
1384
|
header: diffHeader(c, r),
|
|
1177
1385
|
candidate: orientation(c),
|
|
1178
1386
|
reference: orientation(r),
|
|
@@ -1466,8 +1674,18 @@ export function diffLines(report: DiffReport, labels: { candidate: string; refer
|
|
|
1466
1674
|
);
|
|
1467
1675
|
lines.push(...measureLines(report.header.measures, 'skeleton.'.length));
|
|
1468
1676
|
lines.push('');
|
|
1469
|
-
// Wide enough for
|
|
1470
|
-
//
|
|
1677
|
+
// Wide enough for `bones (name-agnostic)` and `animations (reported)`, both
|
|
1678
|
+
// exactly 21, so that most of a section's headings line their figures up
|
|
1679
|
+
// under each other and read as a pair.
|
|
1680
|
+
//
|
|
1681
|
+
// ⚠️ Two headings are longer and push their own figure right instead:
|
|
1682
|
+
// `attachments (reported)`, which has done so since that block existed, and
|
|
1683
|
+
// `animations (name-agnostic)` (issue #720). Widening the column is the
|
|
1684
|
+
// obvious repair and it is the wrong one — it moves every heading line of
|
|
1685
|
+
// every report, and those lines are quoted verbatim in `docs/LADDER.md` and
|
|
1686
|
+
// in the landed run records under `bench/runs/`, which are sealed. A
|
|
1687
|
+
// cosmetic alignment is not worth a byte change in every transcript already
|
|
1688
|
+
// written, and the overflow is visible rather than silent.
|
|
1471
1689
|
const head = (label: string, ratio: number, n: number): string =>
|
|
1472
1690
|
` ${label.padEnd(21)} mean ${fmt(ratio)} over ${n} measures`;
|
|
1473
1691
|
for (const section of report.sections) {
|
|
@@ -1478,7 +1696,9 @@ export function diffLines(report: DiffReport, labels: { candidate: string; refer
|
|
|
1478
1696
|
lines.push('');
|
|
1479
1697
|
lines.push(
|
|
1480
1698
|
`${head(`${section.name} (name-agnostic)`, agnostic.ratio, agnostic.measures.length)}` +
|
|
1481
|
-
' — the same two skeletons compared with names thrown away'
|
|
1699
|
+
' — the same two skeletons compared with names thrown away' +
|
|
1700
|
+
// The pairing is part of the figure, not decoration: see `DiffAgnostic.pairedBy`.
|
|
1701
|
+
(agnostic.pairedBy === undefined ? '' : `, ${agnostic.pairedBy}`),
|
|
1482
1702
|
);
|
|
1483
1703
|
lines.push(...measureLines(agnostic.measures, section.name.length + '.agnostic.'.length));
|
|
1484
1704
|
}
|
|
@@ -1506,6 +1726,12 @@ export function diffLines(report: DiffReport, labels: { candidate: string; refer
|
|
|
1506
1726
|
lines.push(' comparisons, not two halves of one: name-agnostic 1.000 beside a low');
|
|
1507
1727
|
lines.push(' name-matched figure means the shape is right and the vocabulary differs.');
|
|
1508
1728
|
lines.push('');
|
|
1729
|
+
lines.push(' `animations` carries the same pair, and only once something has PAIRED the two');
|
|
1730
|
+
lines.push(' sides\' shots: `--as <candidate>=<reference>`, or one animation each side, which');
|
|
1731
|
+
lines.push(' pairs by position. With neither there is no reading of which shot is which, so');
|
|
1732
|
+
lines.push(' the block is absent rather than guessed — and its absence beside `names` 0.000');
|
|
1733
|
+
lines.push(' is the report saying the candidate named its shots itself and nothing said how.');
|
|
1734
|
+
lines.push('');
|
|
1509
1735
|
lines.push(' `skeleton` is the file\'s own header block and reports two measures for the stage.');
|
|
1510
1736
|
lines.push(' It has no mean for the reason a `(reported)` block never does, and it never');
|
|
1511
1737
|
lines.push(' gates for two: no reading of the frames recovers a setup-pose bounding box, and');
|
package/src/pose.ts
CHANGED
|
@@ -129,7 +129,16 @@ export const COARSE_STRIDE_FRACTION = 0.25;
|
|
|
129
129
|
/** How many scale rungs one octave gets in the coarse ladder. */
|
|
130
130
|
export const SCALE_STEPS_PER_OCTAVE = 3;
|
|
131
131
|
|
|
132
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* The COARSEST step, in degrees, the rotation ladder is allowed to take.
|
|
134
|
+
*
|
|
135
|
+
* ⚠️ A ceiling on the step rather than the step itself, and the distinction is
|
|
136
|
+
* the whole of issue #719. Read as "the step", a window narrower than it prints
|
|
137
|
+
* a resolution the search never had: `--rotation -5,5` reported `step 15°` over
|
|
138
|
+
* a ten-degree window. The ladder therefore divides the window into whole steps
|
|
139
|
+
* no coarser than this — the same shape `scaleLadder` has always had for
|
|
140
|
+
* octaves — and the report states the step that division produced.
|
|
141
|
+
*/
|
|
133
142
|
export const COARSE_ROTATION_STEP = 15;
|
|
134
143
|
|
|
135
144
|
/** Default scale window, as frame pixels per part pixel. */
|
|
@@ -271,7 +280,15 @@ export interface PosePart {
|
|
|
271
280
|
|
|
272
281
|
export interface PoseSearch {
|
|
273
282
|
scale: { min: number; max: number; steps: number };
|
|
274
|
-
|
|
283
|
+
/**
|
|
284
|
+
* The rotation window, and the ladder it produced.
|
|
285
|
+
*
|
|
286
|
+
* 🔒 `stepDeg` is read off `degrees` rather than off `COARSE_ROTATION_STEP`,
|
|
287
|
+
* and `degrees` is the array the coarse pass iterated — so the two cannot say
|
|
288
|
+
* different things about the same run (issue #719). `steps` is how many angles
|
|
289
|
+
* that is, which is `degrees.length`.
|
|
290
|
+
*/
|
|
291
|
+
rotation: { minDeg: number; maxDeg: number; stepDeg: number; steps: number; degrees: number[] };
|
|
275
292
|
/**
|
|
276
293
|
* How the exhaustive first pass was sized. The level it runs at is chosen PER
|
|
277
294
|
* PART — see `PosePart.coarse` — because it depends on how big the part is.
|
|
@@ -783,8 +800,28 @@ function polish(
|
|
|
783
800
|
smooth: boolean,
|
|
784
801
|
/** The scale window the report declares. A polish that walked outside it would report a scale nobody searched. */
|
|
785
802
|
bounds: { min: number; max: number },
|
|
803
|
+
/**
|
|
804
|
+
* The rotation window the report declares, held for exactly the reason above.
|
|
805
|
+
*
|
|
806
|
+
* ⚠️ This argument did not exist until issue #719, and the sentence over
|
|
807
|
+
* `bounds` was the whole argument for it the entire time: a polish free to
|
|
808
|
+
* walk outside the window reports an answer nobody searched, and the window is
|
|
809
|
+
* a field a caller is entitled to read as a promise. `src/chainfit.ts` had
|
|
810
|
+
* already written that argument out for its own hinge — *"for the same reason
|
|
811
|
+
* `pose`'s polish clamps its scale"* — while this file, the one it was citing,
|
|
812
|
+
* clamped one of its two windows. Measured on a rotation window of `-5,5`: the
|
|
813
|
+
* ladder walked its two endpoints and the report came back with 28.1°, 121.3°
|
|
814
|
+
* and −122.3°.
|
|
815
|
+
*
|
|
816
|
+
* A full turn contains every angle, so it is left unclamped and the rotation
|
|
817
|
+
* may wrap — which is what the default window is, and why nothing about a
|
|
818
|
+
* default run moves.
|
|
819
|
+
*/
|
|
820
|
+
rotationBounds: { min: number; max: number; wraps: boolean },
|
|
786
821
|
): Candidate {
|
|
787
822
|
const clamp = (v: number): number => Math.min(bounds.max, Math.max(bounds.min, v));
|
|
823
|
+
const hold = (v: number): number =>
|
|
824
|
+
rotationBounds.wraps ? v : Math.min(rotationBounds.max, Math.max(rotationBounds.min, v));
|
|
788
825
|
let cur: Candidate = { ...start, residual: residualAt(level, plate, s, start, smooth) };
|
|
789
826
|
let dt = step.translate;
|
|
790
827
|
let dr = step.rotate;
|
|
@@ -810,8 +847,8 @@ function polish(
|
|
|
810
847
|
}
|
|
811
848
|
}
|
|
812
849
|
if (dr > floor.rotate) {
|
|
813
|
-
push({ cx: cur.cx, cy: cur.cy, rotDeg: cur.rotDeg + dr, scale: cur.scale });
|
|
814
|
-
push({ cx: cur.cx, cy: cur.cy, rotDeg: cur.rotDeg - dr, scale: cur.scale });
|
|
850
|
+
push({ cx: cur.cx, cy: cur.cy, rotDeg: hold(cur.rotDeg + dr), scale: cur.scale });
|
|
851
|
+
push({ cx: cur.cx, cy: cur.cy, rotDeg: hold(cur.rotDeg - dr), scale: cur.scale });
|
|
815
852
|
}
|
|
816
853
|
if (ds > floor.scale) {
|
|
817
854
|
push({ cx: cur.cx, cy: cur.cy, rotDeg: cur.rotDeg, scale: clamp(cur.scale * (1 + ds)) });
|
|
@@ -997,22 +1034,76 @@ function scaleLadder(min: number, max: number): number[] {
|
|
|
997
1034
|
return out;
|
|
998
1035
|
}
|
|
999
1036
|
|
|
1000
|
-
|
|
1037
|
+
/**
|
|
1038
|
+
* The angles the coarse pass actually walks, evenly dividing the window.
|
|
1039
|
+
*
|
|
1040
|
+
* ⭐ `scaleLadder` above is the shape this follows, and it is the reason the
|
|
1041
|
+
* defect was reachable: that one takes a rung count off its window and divides,
|
|
1042
|
+
* so the rung it reports is the rung it walks. This one used to march
|
|
1043
|
+
* `COARSE_ROTATION_STEP` off the floor and then append the ceiling, which left
|
|
1044
|
+
* two ways for the reported step to be a different number from the applied one —
|
|
1045
|
+
* a window narrower than the constant got its two endpoints and a gap of the
|
|
1046
|
+
* window's own width, and any window whose span is not a whole number of steps
|
|
1047
|
+
* got a short final gap. Both printed `step 15°`.
|
|
1048
|
+
*
|
|
1049
|
+
* ⚠️ The count is a CEILING rather than a rounding, which is not tidiness: a
|
|
1050
|
+
* rounding down would make the applied step wider than `COARSE_ROTATION_STEP`
|
|
1051
|
+
* for a window like 20°, so the constant would stop being an upper bound on the
|
|
1052
|
+
* step. Rounding up cannot coarsen the search — measured against the old ladder,
|
|
1053
|
+
* every window it changes gets at least as many angles as before.
|
|
1054
|
+
*/
|
|
1055
|
+
export function rotationLadder(minDeg: number, maxDeg: number): number[] {
|
|
1001
1056
|
const span = maxDeg - minDeg;
|
|
1002
1057
|
if (span <= 0) return [minDeg];
|
|
1003
|
-
|
|
1004
|
-
if (span >= 360 - 1e-9) {
|
|
1005
|
-
const count = Math.round(360 / COARSE_ROTATION_STEP);
|
|
1006
|
-
const out: number[] = [];
|
|
1007
|
-
for (let i = 0; i < count; i++) out.push(minDeg + (i * 360) / count);
|
|
1008
|
-
return out;
|
|
1009
|
-
}
|
|
1058
|
+
const steps = Math.ceil(span / COARSE_ROTATION_STEP - 1e-9);
|
|
1010
1059
|
const out: number[] = [];
|
|
1011
|
-
for (let
|
|
1012
|
-
|
|
1060
|
+
for (let i = 0; i <= steps; i++) out.push(minDeg + (span * i) / steps);
|
|
1061
|
+
// A full turn's two endpoints are the same rotation, so it gets one of them.
|
|
1062
|
+
if (span >= 360 - 1e-9) out.pop();
|
|
1013
1063
|
return out;
|
|
1014
1064
|
}
|
|
1015
1065
|
|
|
1066
|
+
/** The step a ladder walks, read off the ladder rather than off the constant it was built from. */
|
|
1067
|
+
function ladderStep(degrees: number[]): number {
|
|
1068
|
+
return degrees.length > 1 ? degrees[1] - degrees[0] : 0;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* The `search` line's rotation clause.
|
|
1073
|
+
*
|
|
1074
|
+
* ⭐ Exported for the same reason `windowEdgeNote` is: `docs/AUTHORING.md`
|
|
1075
|
+
* quotes this line, and a guide that spells a report's own sentence by hand is
|
|
1076
|
+
* a second implementation of it. `CUR47` builds the clause here and looks for it
|
|
1077
|
+
* in the page, so the two go stale together or not at all.
|
|
1078
|
+
*/
|
|
1079
|
+
export function searchRotationClause(rotation: PoseSearch['rotation']): string {
|
|
1080
|
+
// Rounded for the console alone — `search.rotation` in the JSON carries the
|
|
1081
|
+
// ladder unrounded, because a window that divides into thirds has angles no
|
|
1082
|
+
// decimal place holds.
|
|
1083
|
+
return `rotation ${rotation.minDeg}°–${rotation.maxDeg}° in ${rotation.steps} step(s) of ${roundTo(rotation.stepDeg, 3)}°`;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* The sentence a refusal carries when its best placement sits on a WALL of the
|
|
1088
|
+
* search window rather than somewhere inside it.
|
|
1089
|
+
*
|
|
1090
|
+
* ⭐ Exported because the guide quotes it and `CUR48` compares the two: a
|
|
1091
|
+
* message and the document that teaches it are the same interface, and the only
|
|
1092
|
+
* way they cannot drift is for one of them to be built from the other.
|
|
1093
|
+
*
|
|
1094
|
+
* The claim is deliberately weak — *may* lie outside — because that is all that
|
|
1095
|
+
* is known. The search was bounded, the optimum walked to the bound and stopped;
|
|
1096
|
+
* whether the truth is past it or the part simply does not appear in this frame
|
|
1097
|
+
* are two readings this instrument cannot separate. Naming the wall is what lets
|
|
1098
|
+
* an author separate them, by moving the wall.
|
|
1099
|
+
*/
|
|
1100
|
+
export function windowEdgeNote(axis: 'scale' | 'rotation', edge: 'floor' | 'ceiling', at: string, window: string): string {
|
|
1101
|
+
return (
|
|
1102
|
+
`best placement at ${axis} ${at}, the ${edge} of --${axis} ${window} — ` +
|
|
1103
|
+
`the truth may lie ${edge === 'floor' ? 'below' : 'above'} the window`
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1016
1107
|
/** The PNGs in a directory, in name order — the parts, and the order the report lists them. */
|
|
1017
1108
|
export function partFiles(imagesDir: string, exclude: string): string[] {
|
|
1018
1109
|
const dir = resolve(imagesDir);
|
|
@@ -1072,7 +1163,18 @@ export function estimatePose(options: PoseOptions): PoseReport {
|
|
|
1072
1163
|
frame: { path: framePath, width: frame.width, height: frame.height, background },
|
|
1073
1164
|
search: {
|
|
1074
1165
|
scale: { min: scaleMin, max: scaleMax, steps: scales.length },
|
|
1075
|
-
rotation: {
|
|
1166
|
+
rotation: {
|
|
1167
|
+
minDeg: rotMin,
|
|
1168
|
+
maxDeg: rotMax,
|
|
1169
|
+
// ⚠️ Neither of these is rounded, and every other number in this report
|
|
1170
|
+
// is. Rounding them would make the reported ladder a near-copy of the
|
|
1171
|
+
// applied one, which is the defect this field exists to close — a window
|
|
1172
|
+
// that divides into thirds has angles no decimal place holds. The console
|
|
1173
|
+
// rounds for display; the record is exact.
|
|
1174
|
+
stepDeg: ladderStep(rotations),
|
|
1175
|
+
steps: rotations.length,
|
|
1176
|
+
degrees: [...rotations],
|
|
1177
|
+
},
|
|
1076
1178
|
coarse: {
|
|
1077
1179
|
frameLongSide: COARSE_LONG_SIDE,
|
|
1078
1180
|
partSpan: COARSE_PART_SPAN,
|
|
@@ -1097,14 +1199,29 @@ export function estimatePose(options: PoseOptions): PoseReport {
|
|
|
1097
1199
|
'only inside the frame canvas. ⚠️ A window that does not contain the true value does NOT reliably ' +
|
|
1098
1200
|
'refuse: a part shrunk inside the region it came from still explains those pixels, so the answer is the ' +
|
|
1099
1201
|
'best placement available INSIDE the window and its residual can look reasonable. That is why the window ' +
|
|
1100
|
-
'is a reported field — if the numbers surprise you, check it before you trust them.'
|
|
1202
|
+
'is a reported field — if the numbers surprise you, check it before you trust them. A refused part whose ' +
|
|
1203
|
+
'best placement stopped ON a wall of the window says so in its own `refusal.detail`, which is the case ' +
|
|
1204
|
+
'where the window is the first thing to move.',
|
|
1101
1205
|
],
|
|
1102
1206
|
parts: [],
|
|
1103
1207
|
};
|
|
1104
1208
|
|
|
1209
|
+
// A window spanning a whole turn contains every angle there is, so nothing is
|
|
1210
|
+
// outside it and nothing has to be held inside it.
|
|
1211
|
+
const rotationBounds = { min: rotMin, max: rotMax, wraps: rotMax - rotMin >= 360 - 1e-9 };
|
|
1105
1212
|
for (const path of paths) {
|
|
1106
1213
|
report.parts.push(
|
|
1107
|
-
placePart(
|
|
1214
|
+
placePart(
|
|
1215
|
+
path,
|
|
1216
|
+
frame,
|
|
1217
|
+
levels,
|
|
1218
|
+
framePyramid,
|
|
1219
|
+
scales,
|
|
1220
|
+
rotations,
|
|
1221
|
+
maxResidual,
|
|
1222
|
+
{ min: scaleMin, max: scaleMax },
|
|
1223
|
+
rotationBounds,
|
|
1224
|
+
),
|
|
1108
1225
|
);
|
|
1109
1226
|
}
|
|
1110
1227
|
return report;
|
|
@@ -1119,6 +1236,7 @@ function placePart(
|
|
|
1119
1236
|
rotations: number[],
|
|
1120
1237
|
maxResidual: number,
|
|
1121
1238
|
scaleBounds: { min: number; max: number },
|
|
1239
|
+
rotationBounds: { min: number; max: number; wraps: boolean },
|
|
1122
1240
|
): PosePart {
|
|
1123
1241
|
const scaleMin = scaleBounds.min;
|
|
1124
1242
|
/** The scale the sample sets are sized for — the middle of the window, and NOT the scale under test. */
|
|
@@ -1303,7 +1421,7 @@ function placePart(
|
|
|
1303
1421
|
}
|
|
1304
1422
|
seeds.push(start);
|
|
1305
1423
|
}
|
|
1306
|
-
candidates = seeds.map((seed) => polish(level, plate, s, seed, step, floor, smooth, scaleBounds));
|
|
1424
|
+
candidates = seeds.map((seed) => polish(level, plate, s, seed, step, floor, smooth, scaleBounds, rotationBounds));
|
|
1307
1425
|
candidates.sort((a, b) => a.residual - b.residual);
|
|
1308
1426
|
// ⚠️ Eight branches that walked to one optimum are one candidate, not eight —
|
|
1309
1427
|
// and the radius has to scale with the PART rather than be a pixel count.
|
|
@@ -1324,20 +1442,31 @@ function placePart(
|
|
|
1324
1442
|
// The one rotation family the translation scan cannot see: a part that is its
|
|
1325
1443
|
// own mirror after a quarter or a half turn sits in the SAME place at more than
|
|
1326
1444
|
// one angle, so the field records only whichever won. Probe them explicitly.
|
|
1445
|
+
//
|
|
1446
|
+
// 🚨 Only the turns the window contains, and this is the other half of #719's
|
|
1447
|
+
// measurement. A quarter turn off is a SEED, not a ladder rung — so under
|
|
1448
|
+
// `--rotation -5,5` it entered the answer from outside a window the report was
|
|
1449
|
+
// calling the search, and the candidate who ran the exam read `rot=91.2°`
|
|
1450
|
+
// under `rotation -5°–5°`. A caller who bounds the rotation has said the part
|
|
1451
|
+
// is not a quarter turn over; the honest response is not to look there rather
|
|
1452
|
+
// than to look and report it.
|
|
1327
1453
|
if (!rotationFree && candidates.length > 0) {
|
|
1328
1454
|
const primary = candidates[0];
|
|
1329
1455
|
const s = samplesFor(1, POLISH_SAMPLES);
|
|
1330
1456
|
for (const turn of [90, 180, 270]) {
|
|
1457
|
+
const turned = primary.rotDeg + turn;
|
|
1458
|
+
if (!rotationBounds.wraps && (turned < rotationBounds.min - 1e-9 || turned > rotationBounds.max + 1e-9)) continue;
|
|
1331
1459
|
candidates.push(
|
|
1332
1460
|
polish(
|
|
1333
1461
|
levels[0],
|
|
1334
1462
|
plates[0],
|
|
1335
1463
|
s,
|
|
1336
|
-
{ ...primary, rotDeg:
|
|
1464
|
+
{ ...primary, rotDeg: turned },
|
|
1337
1465
|
{ translate: 1.5, rotate: 4, scale: 0.04 },
|
|
1338
1466
|
{ translate: 0.05, rotate: 0.1, scale: 0.001 },
|
|
1339
1467
|
true,
|
|
1340
1468
|
scaleBounds,
|
|
1469
|
+
rotationBounds,
|
|
1341
1470
|
),
|
|
1342
1471
|
);
|
|
1343
1472
|
}
|
|
@@ -1373,14 +1502,56 @@ function placePart(
|
|
|
1373
1502
|
);
|
|
1374
1503
|
}
|
|
1375
1504
|
if (best.residual > maxResidual) {
|
|
1505
|
+
// ⭐ The wall the answer stopped against, named in the refusal that reports
|
|
1506
|
+
// it (issue #719). A refusal that states only the residual and the threshold
|
|
1507
|
+
// sends an author to the one remedy that cannot work — every part of a frame
|
|
1508
|
+
// rendered below the scale floor came back refused at the floor, and the
|
|
1509
|
+
// window that could not reach the truth was a line further up the report
|
|
1510
|
+
// nobody was told to read.
|
|
1511
|
+
//
|
|
1512
|
+
// ⚠️ On a refusal and on nothing else. An accepted placement at a wall is an
|
|
1513
|
+
// author who chose the window to bracket the answer, which is the flag
|
|
1514
|
+
// working; saying "the truth may lie outside" over every one of those is how
|
|
1515
|
+
// a warning stops being read. And a window with no interior — `min === max`
|
|
1516
|
+
// — has no wall to be at, so it gets no sentence: being at the only value
|
|
1517
|
+
// there is says nothing about where the truth is.
|
|
1518
|
+
const edges: string[] = [];
|
|
1519
|
+
if (scaleBounds.max > scaleBounds.min) {
|
|
1520
|
+
const window = `${scaleBounds.min},${scaleBounds.max}`;
|
|
1521
|
+
if (best.scale <= scaleBounds.min * (1 + 1e-9)) {
|
|
1522
|
+
edges.push(windowEdgeNote('scale', 'floor', best.scale.toFixed(3), window));
|
|
1523
|
+
} else if (best.scale >= scaleBounds.max * (1 - 1e-9)) {
|
|
1524
|
+
edges.push(windowEdgeNote('scale', 'ceiling', best.scale.toFixed(3), window));
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
if (!rotationBounds.wraps && rotationBounds.max > rotationBounds.min) {
|
|
1528
|
+
const window = `${rotationBounds.min},${rotationBounds.max}`;
|
|
1529
|
+
const said = `${best.rotationDeg.toFixed(1)}°`;
|
|
1530
|
+
// Compared through `normaliseDegrees` because the reported angle is
|
|
1531
|
+
// normalised into (-180, 180] and a window need not be: `--rotation
|
|
1532
|
+
// 170,190` has a ceiling the report spells −170°.
|
|
1533
|
+
if (Math.abs(normaliseDegrees(best.rotationDeg - rotationBounds.min)) <= 1e-6) {
|
|
1534
|
+
edges.push(windowEdgeNote('rotation', 'floor', said, window));
|
|
1535
|
+
} else if (Math.abs(normaliseDegrees(best.rotationDeg - rotationBounds.max)) <= 1e-6) {
|
|
1536
|
+
edges.push(windowEdgeNote('rotation', 'ceiling', said, window));
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1376
1539
|
base.refusal = {
|
|
1377
1540
|
reason: 'no-match',
|
|
1378
|
-
detail:
|
|
1541
|
+
detail:
|
|
1542
|
+
`${name}: the best placement found has residual ${best.residual.toFixed(4)}, above --max-residual ${maxResidual}` +
|
|
1543
|
+
(edges.length === 0 ? '' : `; ${edges.join('; ')}`),
|
|
1379
1544
|
};
|
|
1380
1545
|
base.notes.push(
|
|
1381
1546
|
`${name} matches nowhere in this frame well enough to report. The best placement found is still in ` +
|
|
1382
1547
|
'`placement` — a refusal names why not to trust it, it does not hide it.',
|
|
1383
1548
|
);
|
|
1549
|
+
if (edges.length > 0) {
|
|
1550
|
+
base.notes.push(
|
|
1551
|
+
`${name}'s best placement sits on a wall of the search window, so the window is the first thing to move: ` +
|
|
1552
|
+
`${edges.join('; ')}.`,
|
|
1553
|
+
);
|
|
1554
|
+
}
|
|
1384
1555
|
}
|
|
1385
1556
|
if (best.unexplained > 0.25 && best.residual <= maxResidual) {
|
|
1386
1557
|
base.notes.push(
|
|
@@ -1419,7 +1590,10 @@ export function poseLines(report: PoseReport): string[] {
|
|
|
1419
1590
|
` .. ground ${bgText}`,
|
|
1420
1591
|
` .. parts ${report.images} (${report.parts.length} png)`,
|
|
1421
1592
|
` .. search scale ${report.search.scale.min}–${report.search.scale.max} in ${report.search.scale.steps} step(s) · ` +
|
|
1422
|
-
|
|
1593
|
+
// The step is the ladder's own rather than the constant it was capped at.
|
|
1594
|
+
// Printing the constant here is what issue #719 was: a line that said
|
|
1595
|
+
// `step 15°` over a window ten degrees wide, which no run had ever walked.
|
|
1596
|
+
`${searchRotationClause(report.search.rotation)} · ` +
|
|
1423
1597
|
`refuse above residual ${report.search.maxResidual}`,
|
|
1424
1598
|
];
|
|
1425
1599
|
const width = Math.max(8, ...report.parts.map((p) => p.part.length));
|