spine-rigc 0.8.0 → 0.9.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
@@ -332,6 +332,26 @@ function runGate(result: CompileResult, opts: CompileOptions, profile: ValidateP
332
332
  return report.failures.length;
333
333
  }
334
334
 
335
+ /**
336
+ * One line per mesh kind on this cut, printed above the table.
337
+ *
338
+ * A legend rather than a heading: the heading used to describe the ring tier
339
+ * unconditionally, so a build whose only mesh was a ribbon or a contour got a
340
+ * sentence about a rim ring and a seam it does not have.
341
+ */
342
+ const MESH_KIND_NOTES: Record<CompileResult['meshes'][number]['kind'], string> = {
343
+ ring: 'ring rim ring pinned on the window edge, seam ring pinned on the mask contour, aperture moves',
344
+ ribbon: 'ribbon entry row pinned, rows share their weights so the strip lengthens without widening',
345
+ contour: 'contour the art\'s own silhouette, every vertex pinned to the slot bone (geometry, not a deformation)',
346
+ authored: 'authored geometry rigc did not build; it assumes nothing about the topology',
347
+ };
348
+
349
+ /** What a contour mesh measured about its own fit, or nothing for the others. */
350
+ function meshFit(m: CompileResult['meshes'][number]): string {
351
+ if (m.coverage === undefined) return '';
352
+ return ` covers ${(m.coverage * 100).toFixed(2)}% of the art, reaching ${m.overshoot?.toFixed(2) ?? '?'}px past it`;
353
+ }
354
+
335
355
  function cmdBuild(flags: Record<string, string>): void {
336
356
  const { label, opts } = resolveCut(flags);
337
357
  const profile = readProfile(flags);
@@ -358,8 +378,8 @@ function cmdBuild(flags: Record<string, string>): void {
358
378
  }
359
379
  for (const m of result.meshes) {
360
380
  console.log(
361
- ` MESH ${m.slot.padEnd(12)} ${m.kind.padEnd(6)} ${m.vertices} vertices / ${m.triangles} triangles ` +
362
- `(budget 80) bones=[${m.bones.join(', ')}] attachments=[${m.attachments.join(', ')}]`,
381
+ ` MESH ${m.slot.padEnd(12)} ${m.kind.padEnd(8)} ${m.vertices} vertices / ${m.triangles} triangles ` +
382
+ `(budget 80) bones=[${m.bones.join(', ')}] attachments=[${m.attachments.join(', ')}]${meshFit(m)}`,
363
383
  );
364
384
  }
365
385
  for (const ph of result.physics) {
@@ -508,8 +528,10 @@ function cmdDiff(flags: Record<string, string>, positional: string[]): void {
508
528
  *
509
529
  * There is no pass mark, for the same reason `diff` has none.
510
530
  */
511
- function readCheckFlags(flags: Record<string, string>): Pick<CheckOptions, 'fps' | 'viewport' | 'as' | 'framing'> {
512
- const out: Pick<CheckOptions, 'fps' | 'viewport' | 'as' | 'framing'> = {};
531
+ function readCheckFlags(
532
+ flags: Record<string, string>,
533
+ ): Pick<CheckOptions, 'fps' | 'viewport' | 'as' | 'framing' | 'textureFrom'> {
534
+ const out: Pick<CheckOptions, 'fps' | 'viewport' | 'as' | 'framing' | 'textureFrom'> = {};
513
535
  if (flags.framing !== undefined) {
514
536
  if (flags.framing !== 'per-shot' && flags.framing !== 'shared') {
515
537
  throw new UsageError('--framing takes per-shot (the default) or shared');
@@ -530,6 +552,16 @@ function readCheckFlags(flags: Record<string, string>): Pick<CheckOptions, 'fps'
530
552
  out.viewport = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
531
553
  }
532
554
  if (flags.as !== undefined) out.as = flags.as;
555
+ if (flags['texture-from'] !== undefined) {
556
+ const path = resolve(flags['texture-from']);
557
+ if (!existsSync(path)) {
558
+ throw new UsageError(
559
+ `--texture-from ${path} is not a file. It takes the ATLAS the reference frames were rendered through — the ` +
560
+ "example's own .atlas — so check can measure how much of the MAE is texture resampling.",
561
+ );
562
+ }
563
+ out.textureFrom = { atlasText: readFileSync(path, 'utf8'), atlasDir: dirname(path), label: flags['texture-from'] };
564
+ }
533
565
  return out;
534
566
  }
535
567
 
@@ -1368,6 +1400,24 @@ function cmdExplain(flags: Record<string, string>): void {
1368
1400
  }
1369
1401
  }
1370
1402
  }
1403
+ // The other two constraint groups. These DO carry a timeline name under the
1404
+ // constraint (`path.<name>.position`), which is the physics shape rather
1405
+ // than the ik/transform one, so the name printed is both.
1406
+ for (const group of ['path', 'slider'] as const) {
1407
+ for (const [name, timelines] of Object.entries(anim[group] ?? {})) {
1408
+ for (const [timelineName, keys] of Object.entries(timelines)) {
1409
+ console.log(` ${group}.${name}.${timelineName} ${keys.length} key(s)`);
1410
+ for (const key of keys) {
1411
+ const fields = Object.entries(key)
1412
+ .filter(([k]) => k !== 'time' && k !== 'curve')
1413
+ .map(([k, v]) => `${k}=${String(v)}`)
1414
+ .join(' ');
1415
+ const curve = Array.isArray(key.curve) ? `bezier[${key.curve.length}]` : key.curve === 'stepped' ? 'stepped' : 'linear';
1416
+ console.log(` t=${String(key.time).padEnd(7)} ${(fields || '(all defaults)').padEnd(46)} ${curve}`);
1417
+ }
1418
+ }
1419
+ }
1420
+ }
1371
1421
  // Deform timelines are keyed on a skin/slot/attachment triple, and the run
1372
1422
  // is printed as its span rather than its numbers: `offset` plus a length is
1373
1423
  // what tells a reader whether the key lands where they meant, and a hundred
@@ -1413,11 +1463,66 @@ function cmdExplain(flags: Record<string, string>): void {
1413
1463
  }
1414
1464
  }
1415
1465
 
1466
+ // Path constraints, with the curve each one follows MEASURED — its length and
1467
+ // its curve count are the two numbers an author cannot get from the spec, and
1468
+ // `position` means nothing without the first of them under `positionMode:
1469
+ // "percent"`. Read off the emitted attachment rather than recomputed here.
1470
+ const constraintsOf = (type: string) => (result.skeleton.constraints ?? []).filter((c) => c.type === type);
1471
+ const pathConstraints = constraintsOf('path');
1472
+ if (pathConstraints.length) {
1473
+ console.log('\npath constraints (position is a fraction of the measured length under positionMode "percent")');
1474
+ for (const c of pathConstraints) {
1475
+ const slot = String(c.slot);
1476
+ const attachments = result.skeleton.skins.flatMap((skin) => Object.values(skin.attachments[slot] ?? {}));
1477
+ const curve = attachments.find((att) => (att as { type?: string }).type === 'path') as
1478
+ | { lengths?: number[]; closed?: boolean; constantSpeed?: boolean }
1479
+ | undefined;
1480
+ const lengths = curve?.lengths ?? [];
1481
+ console.log(
1482
+ ` ${c.name.padEnd(12)} slot=${slot.padEnd(12)} bones=[${(c.bones as string[]).join(', ')}] ` +
1483
+ `position=${c.position ?? 0} ${String(c.positionMode ?? 'percent')}/${String(c.spacingMode ?? 'length')}/${String(c.rotateMode ?? 'tangent')}`,
1484
+ );
1485
+ console.log(
1486
+ ` ${''.padEnd(12)} curve: ${lengths.length} curve(s), ${lengths[lengths.length - 1] ?? 0} long, ` +
1487
+ `${curve?.closed ? 'closed' : 'open'}, constantSpeed=${curve?.constantSpeed ?? true}`,
1488
+ );
1489
+ }
1490
+ }
1491
+
1492
+ const sliders = constraintsOf('slider');
1493
+ if (sliders.length) {
1494
+ console.log('\nsliders (each applies one animation at a time it chooses)');
1495
+ for (const c of sliders) {
1496
+ const driver = c.bone === undefined ? `time=${c.time ?? 0} (keyed by slider.${c.name}.time)` : `bone=${String(c.bone)}.${String(c.property)}`;
1497
+ console.log(
1498
+ ` ${c.name.padEnd(12)} applies=${String(c.animation).padEnd(14)} ${driver} ` +
1499
+ `mix=${c.mix ?? 1} loop=${c.loop ?? false} additive=${c.additive ?? false}`,
1500
+ );
1501
+ }
1502
+ }
1503
+
1504
+ // Which bones and constraints a skin switches on. Printed because the pairing
1505
+ // with `skin: true` is invisible in the emitted file: a member list and a
1506
+ // skinRequired flag are two keys in two places, and only together do they mean
1507
+ // "this bone belongs to this skin".
1508
+ const skinMembers = result.skeleton.skins.filter((skin) => skin.bones?.length || skin.ik?.length || skin.transform?.length || skin.path?.length || skin.physics?.length || skin.slider?.length);
1509
+ if (skinMembers.length) {
1510
+ console.log('\nskin members (skinRequired bones and constraints, active only under their own skin)');
1511
+ for (const skin of skinMembers) {
1512
+ const lists = (['bones', 'ik', 'transform', 'path', 'physics', 'slider'] as const)
1513
+ .filter((key) => skin[key]?.length)
1514
+ .map((key) => `${key}=[${skin[key]!.join(', ')}]`)
1515
+ .join(' ');
1516
+ console.log(` ${skin.name.padEnd(12)} ${lists}`);
1517
+ }
1518
+ }
1519
+
1416
1520
  if (result.meshes.length) {
1417
- console.log('\nmeshes (ring tier: rim ring pinned on the window edge, seam ring pinned on the mask contour)');
1521
+ console.log('\nmeshes');
1522
+ for (const kind of new Set(result.meshes.map((m) => m.kind))) console.log(` ${MESH_KIND_NOTES[kind]}`);
1418
1523
  for (const m of result.meshes) {
1419
1524
  console.log(
1420
- ` ${m.slot.padEnd(12)} ${m.kind.padEnd(6)} ${m.vertices} vertices / ${m.triangles} triangles bones=[${m.bones.join(', ')}]`,
1525
+ ` ${m.slot.padEnd(12)} ${m.kind.padEnd(8)} ${m.vertices} vertices / ${m.triangles} triangles bones=[${m.bones.join(', ')}]${meshFit(m)}`,
1421
1526
  );
1422
1527
  }
1423
1528
  }
@@ -1456,6 +1561,11 @@ const FLAG_MEANINGS: Record<string, string> = {
1456
1561
  'which rulebook to check against (default: spine) — spine = valid Spine 4.3 that any runtime plays ' +
1457
1562
  "correctly; spine-html = also this project's renderer/archetype policy",
1458
1563
  atlas: "the candidate's atlas, when it is not beside the skeleton",
1564
+ 'texture-from':
1565
+ "also measure this run through this atlas's texels, keeping the candidate's own geometry, and report how much " +
1566
+ 'of the MAE is texture resampling rather than the rig — pass the atlas the reference frames were rendered ' +
1567
+ 'through. ⚠️ NOT --atlas: that one names the candidate\'s own atlas and loading a foreign one there re-seats ' +
1568
+ 'every region attachment on its packing, so a rotated or trimmed pack moves the geometry too',
1459
1569
  candidate: 'a compiled skeleton: a directory holding skeleton.json + skeleton.atlas, or a skeleton.json path',
1460
1570
  frames: 'a rendered reference frame set (a skeleton root, or one animation directory)',
1461
1571
  fps: 'frame rate, only for a frame set with no frames.json sidecar',
@@ -1490,6 +1600,7 @@ const FLAG_VALUES: Record<string, string> = {
1490
1600
  cuts: '<path>',
1491
1601
  profile: 'spine|spine-html',
1492
1602
  atlas: '<path>',
1603
+ 'texture-from': '<path>',
1493
1604
  candidate: '<dir|skeleton.json>',
1494
1605
  frames: '<dir>',
1495
1606
  fps: '<n>',
@@ -1560,7 +1671,7 @@ const COMMANDS: CommandDoc[] = [
1560
1671
  {
1561
1672
  name: 'check',
1562
1673
  usage: ['rigc check --candidate <dir | skeleton.json> --frames <dir> [flags]'],
1563
- flags: ['candidate', 'frames', 'atlas', 'fps', 'viewport', 'framing', 'as', 'all-frames', 'json'],
1674
+ flags: ['candidate', 'frames', 'atlas', 'texture-from', 'fps', 'viewport', 'framing', 'as', 'all-frames', 'json'],
1564
1675
  },
1565
1676
  {
1566
1677
  name: 'bench',