spine-rigc 0.27.0 → 0.29.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);