spine-rigc 0.8.1 → 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/README.md +14 -5
- package/cli.ts +118 -7
- package/docs/AUTHORING.md +524 -97
- package/docs/SPEC_COVERAGE.md +27 -12
- package/package.json +1 -1
- package/src/check.ts +552 -29
- package/src/compile.ts +674 -48
- package/src/mesh.ts +832 -3
- package/src/render.ts +300 -7
- package/src/rig.ts +448 -20
- package/src/transform.ts +13 -0
- package/src/types.ts +82 -7
- package/src/validate.ts +347 -38
package/README.md
CHANGED
|
@@ -394,9 +394,9 @@ work on any frames you have, and `bench` is a repository workflow that needs a c
|
|
|
394
394
|
and `bun run fetch-examples`. The reasoning behind all three is in
|
|
395
395
|
[the benchmark dossier](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md).
|
|
396
396
|
|
|
397
|
-
`build` and `validate` both default to `--profile spine` — the
|
|
397
|
+
`build` and `validate` both default to `--profile spine` — the 25 validity rules, which
|
|
398
398
|
ask *is this valid Spine 4.3 that any runtime plays correctly?* `--profile spine-html`
|
|
399
|
-
adds all
|
|
399
|
+
adds all 39: the other 14 are one renderer's policy and one canvas budget's, and they
|
|
400
400
|
fire on perfectly correct editor-produced Spine data, so reach for that profile when
|
|
401
401
|
you are shipping into *that* project rather than to be thorough. A report always names
|
|
402
402
|
the profile it ran and lists what that profile left out.
|
|
@@ -424,13 +424,22 @@ rigc is measured against **Spine's own official example projects** — the
|
|
|
424
424
|
`1-weight-and-mass` … `8-follow-through` series as a difficulty ladder, with spineboy
|
|
425
425
|
as the graduation exam.
|
|
426
426
|
|
|
427
|
-
🎓 **The ladder
|
|
428
|
-
spineboy graduation exam
|
|
427
|
+
🎓 **The ladder was completed 2026-08-28.** All eight numbered rungs and the
|
|
428
|
+
spineboy graduation exam cleared under gate v2.1 and held under **v2.2**, every clause PASS or SKIP:
|
|
429
429
|
worst attributable slot drift **5.55 px** against a 6.0 px bar, and **0 of 124**
|
|
430
430
|
frame-change disagreements. Recompiling the same spec in a different session
|
|
431
431
|
reproduced every field of the measurement record **to the digit**. The rungs stay
|
|
432
432
|
in place as regression gates.
|
|
433
433
|
|
|
434
|
+
⚠️ **Current state, 2026-09-02: seven of the eight hold, and rung 7 is open.**
|
|
435
|
+
`check`'s extent tolerance ([PR #254](https://github.com/firejune/rigc/pull/254))
|
|
436
|
+
changed which box a set is measured in, and rung 7's stored candidate fails **G2**
|
|
437
|
+
under it — one of its three slots draws in every set and is attributable in none,
|
|
438
|
+
and no read-down kind survives the framing change. **Rungs 1–6 and 8 and the
|
|
439
|
+
graduation exam are unaffected**: each reproduces its gated figures to the digit,
|
|
440
|
+
and the 5.55 px and 0-of-124 figures above are among them. Verdict and reasoning
|
|
441
|
+
in [docs/LADDER.md](docs/LADDER.md)'s *PR #254 instrument re-inspection*.
|
|
442
|
+
|
|
434
443
|
⚠️ **What that certifies, stated exactly.** That **the tool, the guide and the
|
|
435
444
|
protocol reach the bar across a bounded series of honest attempts, each residual
|
|
436
445
|
diagnosed and fixed** — spineboy took five, and the last inherited its
|
|
@@ -439,7 +448,7 @@ that an agent authors a spineboy-scale rig from the brief alone in one run: the
|
|
|
439
448
|
ladder has not demonstrated that, and each row records which of the two it is.
|
|
440
449
|
|
|
441
450
|
The whole dossier — the yardstick, `diff` and `check` and what neither of them can
|
|
442
|
-
see, every rung, the run viewer, the
|
|
451
|
+
see, every rung, the run viewer, the 39 assertions and the selftest behind them — is
|
|
443
452
|
[docs/BENCHMARK.md](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md).
|
|
444
453
|
Live rung status is
|
|
445
454
|
[docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md).
|
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(
|
|
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(
|
|
512
|
-
|
|
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
|
|
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(
|
|
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',
|