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/README.md +125 -716
- package/cli.ts +118 -7
- package/docs/AUTHORING.md +524 -97
- package/docs/SPEC_COVERAGE.md +27 -12
- package/package.json +4 -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/tools/contact.ts +126 -0
- package/tools/measure_contact_depth.ts +105 -0
- package/tools/png_probe.mjs +72 -0
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',
|