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 CHANGED
@@ -66,10 +66,24 @@ import {
66
66
  type DeformKeyMeasure,
67
67
  type DeformSpan,
68
68
  } from './src/deformmeasure.ts';
69
- import { diffLines, diffSkeletons, reportedFigures, sectionFigures, type DiffReport } from './src/diff.ts';
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 { DEFAULT_PADDING, DEFAULT_PAGE_SIZE, packAtlas, parseAtlasText } from './src/atlas.ts';
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
- * Only `vote --candidate` is, because a ballot is *by definition* several
222
- * candidates. Everywhere else a repeat is a mistake and is refused: `check
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
- function cmdDiff(flags: Record<string, string>, positional: string[]): void {
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 report = diffSkeletons(readJsonFile(candidatePath), readJsonFile(referencePath));
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). Both sides may be foreign; the interesting pairing during ingest is
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.27.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 ? {} : { nameAgnostic: { ratio: meanRatio(nameAgnostic), measures: nameAgnostic } }),
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
- function diffAnimations(c: Json, r: Json): DiffSection {
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
- undefined,
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: [diffBones(c, r), diffSlots(c, r), diffAttachments(c, r), diffConstraints(c, r), diffAnimations(c, r), diffEvents(c, r)],
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 `<longest section> (name-agnostic)`, so that a section's two
1470
- // headings line their figures up under each other and read as a pair.
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
- /** The coarse rotation ladder's step, in degrees. */
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
- rotation: { minDeg: number; maxDeg: number; stepDeg: number; steps: number };
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
- function rotationLadder(minDeg: number, maxDeg: number): number[] {
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
- // A full turn's two endpoints are the same rotation, so it gets one of them.
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 deg = minDeg; deg <= maxDeg + 1e-9; deg += COARSE_ROTATION_STEP) out.push(deg);
1012
- if (out[out.length - 1] < maxDeg - 1e-9) out.push(maxDeg);
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: { minDeg: rotMin, maxDeg: rotMax, stepDeg: COARSE_ROTATION_STEP, steps: rotations.length },
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(path, frame, levels, framePyramid, scales, rotations, maxResidual, { min: scaleMin, max: scaleMax }),
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: primary.rotDeg + turn },
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: `${name}: the best placement found has residual ${best.residual.toFixed(4)}, above --max-residual ${maxResidual}`,
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
- `rotation ${report.search.rotation.minDeg}°–${report.search.rotation.maxDeg}° step ${report.search.rotation.stepDeg}° · ` +
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));