spine-rigc 1.2.3 → 1.4.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 CHANGED
@@ -530,12 +530,12 @@ commands take it and what its default is.
530
530
  | Command | Does |
531
531
  | --- | --- |
532
532
  | `build --rig … --motion … --out …` | compiles, gates, and **writes only if the gate is green**. `--images <dir>` says where the rig spec's `image` names resolve, `--manifest` adds measured art, and `--copy-images` copies every page PNG into `--out` so the directory is self-contained, with `skeleton.images` pointing at it so the editor's import finds the parts |
533
- | `build … --pack` | the same build with every part arranged onto **shared** atlas pages, written into `--out` — losslessly, and gated a second time as the pair that ships. `--page-size` and `--padding` tune it |
533
+ | `build … --pack` | the same build with every part arranged onto **shared** atlas pages, written into `--out` — losslessly, and gated a second time as the pair that ships. `--page-size`, `--padding` and `--page-edges` tune it |
534
534
  | `build … --atlas-in <file.atlas>` | the same build with every part resolved to a **region of an existing pack** instead of a loose PNG; a name the atlas lacks, a size the spec disagrees with or a rectangle off its page is refused by name |
535
535
  | `validate <dir>` | re-gates artifacts already on disk |
536
536
  | `ingest <skeleton.json> --out <dir>` | `build` run backwards: reads a Spine 4.3 skeleton and writes the rig spec and motion spec that **rebuild it**, plus a findings report naming everything it could not carry. a skeleton that declares no stage is carried as declaring none, `--stage x,y,w,h` adds a box to one — and is refused, rather than ignored, beside one that declares a box — and `--images <dir>` writes the spec's own images directory — the opposite direction from `build --images`, which overrides it — so the rebuild carries no flag at all |
537
537
  | `explain --rig … --motion …` | the compiled rig as a table — every bone with its resolved parent, the slots in draw order, every timeline key by key. Writes nothing. What to reach for when a rig compiles and still looks wrong |
538
- | `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`. `--hide <slot,…>` or `--slot <slot,…>` draws part of the rig on the **same grid** as the whole, so the two frames overlay and the difference is the part; `frames.json` records the subset and `check` refuses such a set as a reference |
538
+ | `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`. `--hide <slot,…>` or `--slot <slot,…>` draws part of the rig on the **same grid** as the whole, so the two frames overlay and the difference is the part; `frames.json` records the subset and `check` refuses such a set as a reference. `--geometry` adds a `geometry.json` per set: every frame's bone world transforms and skinned attachment vertices, with the rest geometry, on the frames' own grid |
539
539
  | `preview --candidate <dir>` | one self-contained `.html` that plays it, headed by the line `validate <dir>` prints for it and the rigc version — a refused candidate is still previewed, and its header says so in the gate's words. Repeat `--candidate` for one page with a pane per candidate, in the order given; a green `build` ends by naming this command for its own `--out` |
540
540
  | `vote --candidate a --candidate b` | one `.html` that asks a human which; `vote --record <file>` checks the answer into `votes.jsonl` |
541
541
  | `pose --images <dir> --frame <png>` | reads part placements **out of** a picture |
package/cli.ts CHANGED
@@ -101,8 +101,12 @@ import { NotAPngError } from './src/png.ts';
101
101
  import { copyAtlasPages } from './src/emit.ts';
102
102
  import {
103
103
  DEFAULT_PADDING,
104
+ DEFAULT_PAGE_EDGES,
104
105
  DEFAULT_PAGE_SIZE,
106
+ FREE_EDGE_STEP,
107
+ PAGE_EDGES,
105
108
  packAtlas,
109
+ type PageEdges,
106
110
  pageFootprint,
107
111
  parseAtlasText,
108
112
  type AtlasRegion,
@@ -141,6 +145,10 @@ import {
141
145
  FRAMES_SIDECAR,
142
146
  FRAMES_SPEC,
143
147
  framingViewport,
148
+ GEOMETRY_FILE,
149
+ GeometryError,
150
+ geometryFileOf,
151
+ geometryText,
144
152
  loadPosable,
145
153
  PROTOCOL_FPS,
146
154
  renderFrame,
@@ -149,6 +157,7 @@ import {
149
157
  SETUP_POSE_DIR,
150
158
  SHEET_FILE,
151
159
  SHEET_TILE,
160
+ sidecarViewport,
152
161
  SlotSubsetError,
153
162
  slotSubsetOf,
154
163
  type Frame,
@@ -255,7 +264,7 @@ function repositoryUrl(): string {
255
264
  * needs a value` (issue #328). `CLI10`/`CLI11` in `selftest.ts` now hold the two
256
265
  * halves together by reading `--help` rather than by naming a flag.
257
266
  */
258
- const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy']);
267
+ const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry']);
259
268
 
260
269
  /**
261
270
  * The flags a command is allowed to spell more than once.
@@ -612,9 +621,37 @@ const MESH_KIND_NOTES: Record<CompileResult['meshes'][number]['kind'], string> =
612
621
  ribbon: 'ribbon entry row pinned, rows share their weights so the strip lengthens without widening',
613
622
  contour: 'contour the art\'s own silhouette, every vertex pinned to the slot bone (geometry, not a deformation)',
614
623
  grid: 'grid a lattice over the part window at stated column and row positions, every vertex pinned to the slot bone',
624
+ segments: 'segments a lattice over the part\'s alpha, every vertex weighted by distance to the bone segments it names',
615
625
  authored: 'authored geometry rigc did not build; it assumes nothing about the topology',
616
626
  };
617
627
 
628
+ /**
629
+ * The line under a `segments` mesh: how its bones share the vertices.
630
+ *
631
+ * ⭐ The weights are this generator's whole output, and the per-vertex numbers
632
+ * are nowhere a reader can see them — so the three figures that say whether the
633
+ * falloff did what was meant are printed where the mesh is: the most bones any
634
+ * vertex binds (against `maxBones`), the mean, and how many vertices a single
635
+ * bone owns outright. A named bone no vertex binds is said by name, because it
636
+ * is in no weight and so in no other figure either.
637
+ */
638
+ function meshInfluenceNote(m: CompileResult['meshes'][number]): string {
639
+ const inf = m.influence;
640
+ if (inf === undefined) return '';
641
+ const unbound = m.bones.filter((b) => !inf.bound.includes(b));
642
+ const single = m.vertices === 0 ? 0 : (inf.singleBone / m.vertices) * 100;
643
+ const joined = inf.keptCells - inf.artCells;
644
+ return (
645
+ `\n influence max ${inf.maxBones} bone(s) per vertex, mean ${inf.meanBones.toFixed(2)}, ` +
646
+ `${inf.singleBone} of ${m.vertices} vertices (${single.toFixed(2)}%) on a single bone` +
647
+ (unbound.length ? `; named and bound by no vertex: ${unbound.join(', ')}` : '') +
648
+ `\n lattice cell ${inf.cell}px, ${inf.cols}x${inf.rows} cells, ${inf.artCells} with art, ${inf.keptCells} kept` +
649
+ (inf.islands > 1 || joined > 0
650
+ ? ` (${inf.islands} island(s) joined into one outline, ${joined} cell(s) added that hold no art)`
651
+ : '')
652
+ );
653
+ }
654
+
618
655
  /**
619
656
  * Why a figure taken off a part's texels is withheld on a page whose file is
620
657
  * not the size its atlas declares — the tail of every line that withholds one
@@ -1260,6 +1297,21 @@ function readIntFlag(flags: Record<string, string>, name: string, fallback: numb
1260
1297
  return Number(raw);
1261
1298
  }
1262
1299
 
1300
+ /**
1301
+ * Read `--page-edges`, or its default — `pot`.
1302
+ *
1303
+ * An unknown value is a usage error for `readProfile`'s reason: a typo that fell
1304
+ * back to `pot` would hand the caller who asked for the smaller page the bigger
1305
+ * one, green, and say nothing.
1306
+ */
1307
+ function readPageEdges(flags: Record<string, string>): PageEdges {
1308
+ const raw = flags['page-edges'];
1309
+ if (raw === undefined) return DEFAULT_PAGE_EDGES;
1310
+ const found = PAGE_EDGES.find((e) => e === raw);
1311
+ if (!found) throw new UsageError(`--page-edges ${JSON.stringify(raw)}; known values: ${PAGE_EDGES.join(', ')}`);
1312
+ return found;
1313
+ }
1314
+
1263
1315
  /**
1264
1316
  * One `DROP` line, written once because two outcomes print it.
1265
1317
  *
@@ -1343,7 +1395,7 @@ function cmdBuild(flags: Record<string, string>): void {
1343
1395
  // now a build like any other — and it is the only one that puts the renderer's
1344
1396
  // own rulebook over shared-page sampling.
1345
1397
  if (!packing) {
1346
- for (const name of ['page-size', 'padding'] as const) {
1398
+ for (const name of ['page-size', 'padding', 'page-edges'] as const) {
1347
1399
  if (flags[name] !== undefined) throw new UsageError(`--${name} only means something with --pack`);
1348
1400
  }
1349
1401
  }
@@ -1394,7 +1446,8 @@ function cmdBuild(flags: Record<string, string>): void {
1394
1446
  console.log(
1395
1447
  ` MESH ${m.slot.padEnd(12)} ${m.kind.padEnd(8)} ${m.vertices} vertices / ${m.triangles} triangles ` +
1396
1448
  `${meshBudget(result.rig)} bones=[${m.bones.join(', ')}] attachments=[${m.attachments.join(', ')}]${meshFit(m)}` +
1397
- meshDepthNote(m),
1449
+ meshDepthNote(m) +
1450
+ meshInfluenceNote(m),
1398
1451
  );
1399
1452
  }
1400
1453
  for (const ph of result.physics) {
@@ -1445,6 +1498,7 @@ function cmdBuild(flags: Record<string, string>): void {
1445
1498
  const packOpts = {
1446
1499
  pageSize: readIntFlag(flags, 'page-size', DEFAULT_PAGE_SIZE),
1447
1500
  padding: readIntFlag(flags, 'padding', DEFAULT_PADDING),
1501
+ pageEdges: readPageEdges(flags),
1448
1502
  pageStem: 'skeleton',
1449
1503
  };
1450
1504
  const inputs = result.images.map((img) => ({
@@ -1460,7 +1514,8 @@ function cmdBuild(flags: Record<string, string>): void {
1460
1514
  console.log(
1461
1515
  ` .. pack: ${page.name} ${page.width}x${page.height}, ` +
1462
1516
  `${packed.placements.filter((p) => packed.pages[p.page].name === page.name).length} region(s), ` +
1463
- `${(page.occupancy * 100).toFixed(1)}% covered, padding ${packed.padding}`,
1517
+ `${(page.occupancy * 100).toFixed(1)}% covered, padding ${packed.padding}` +
1518
+ (packOpts.pageEdges === 'free' ? ', page edges free' : ''),
1464
1519
  );
1465
1520
  }
1466
1521
  for (const place of packed.placements) {
@@ -1960,6 +2015,24 @@ function cmdRender(flags: Record<string, string>): void {
1960
2015
  const fps = readPositiveNumber(flags, 'fps', PROTOCOL_FPS, 1);
1961
2016
  const maxSide = readPositiveNumber(flags, 'max', 256, 16);
1962
2017
  const outRoot = resolve(flags.out ?? 'render');
2018
+ const geometry = flags.geometry !== undefined;
2019
+ // Refused together rather than one of them ignored (issue #864). The export
2020
+ // records every slot's whole geometry, because a subset is a statement about
2021
+ // which pixels are DRAWN and the pose is the same whatever a picture leaves
2022
+ // out — so a geometry.json beside a subset's frames would describe slots
2023
+ // those frames do not draw, and one that dropped them would stop being the
2024
+ // pose. The whole-rig run writes the same file either way.
2025
+ if (geometry) {
2026
+ for (const flag of ['slot', 'hide'] as const) {
2027
+ if (flags[flag] !== undefined) {
2028
+ throw new UsageError(
2029
+ `--geometry takes no --${flag}: it records every slot's whole geometry, which a subset of the drawn slots ` +
2030
+ `does not change. Run \`rigc render --geometry\` on the whole rig for the file, and --${flag} ` +
2031
+ `${flags[flag]} without --geometry for the pictures — both land on the same grid`,
2032
+ );
2033
+ }
2034
+ }
2035
+ }
1963
2036
 
1964
2037
  console.log('rigc render');
1965
2038
  console.log(` .. skeleton ${skeletonPath}`);
@@ -1993,8 +2066,24 @@ function cmdRender(flags: Record<string, string>): void {
1993
2066
  // `sampleAll` covers the skeleton with no animation at all, which files its one
1994
2067
  // setup-pose frame under the reserved name. Narrowing to one animation reuses
1995
2068
  // the same sampler rather than a second path through it.
2069
+ //
2070
+ // `--geometry` rides on the SAME call (issue #864): the bones and the whole
2071
+ // attachments are read off the skeleton at the step that drew each frame, so
2072
+ // the export's grid is this frame set's by construction.
2073
+ const sampling = geometry ? { ...pose, bones: true, geometry: true } : pose;
1996
2074
  const sampled: Map<string, Frame[]> =
1997
- only === undefined ? sampleAll(data, fps, pose) : new Map([[only, sampleAnimation(data, only, fps, pose)]]);
2075
+ only === undefined
2076
+ ? sampleAll(data, fps, sampling)
2077
+ : new Map([[only, sampleAnimation(data, only, fps, sampling)]]);
2078
+ // Every file's text before the first write, so a refused number leaves the
2079
+ // output directory as it was rather than half of a frame set behind it.
2080
+ const geometryTexts = new Map<string, string>();
2081
+ if (geometry) {
2082
+ for (const [name, frames] of sampled) {
2083
+ const animation = name === SETUP_POSE_DIR && data.animations.length === 0 ? null : name;
2084
+ geometryTexts.set(name, geometryText(geometryFileOf(data, animation, fps, frames, viewport, skin)));
2085
+ }
2086
+ }
1998
2087
  console.log(` .. ${viewport.width}x${viewport.height}px at ${fps} fps, ${sampled.size} set(s) -> ${outRoot}`);
1999
2088
  if (!declaresSetupStage(data)) console.log(` .. ${STAGELESS_FRAMING.render}`);
2000
2089
 
@@ -2017,6 +2106,8 @@ function cmdRender(flags: Record<string, string>): void {
2017
2106
  // would be the same picture with a border and a "0" on it.
2018
2107
  const sheet = frames.length > 1;
2019
2108
  if (sheet) contactSheet(frames, pages, viewport, SHEET_TILE).writePng(join(dir, SHEET_FILE));
2109
+ const geometryOut = geometryTexts.get(name);
2110
+ if (geometryOut !== undefined) writeFileSync(join(dir, GEOMETRY_FILE), geometryOut);
2020
2111
  const duration = frames[frames.length - 1].time;
2021
2112
  sets.push({
2022
2113
  dir: dirName,
@@ -2028,7 +2119,8 @@ function cmdRender(flags: Record<string, string>): void {
2028
2119
  duration,
2029
2120
  });
2030
2121
  const how = frames.length === 1 ? 'a single pose' : `${duration.toFixed(3)}s`;
2031
- console.log(` .. ${name.padEnd(16)} ${frames.length} frame(s), ${how}${sheet ? ` + ${SHEET_FILE}` : ''} -> ${dir}`);
2122
+ const extras = `${sheet ? ` + ${SHEET_FILE}` : ''}${geometryOut === undefined ? '' : ` + ${GEOMETRY_FILE}`}`;
2123
+ console.log(` .. ${name.padEnd(16)} ${frames.length} frame(s), ${how}${extras} -> ${dir}`);
2032
2124
  }
2033
2125
 
2034
2126
  // The sidecar is what makes this a frame SET rather than a pile of pictures:
@@ -2045,15 +2137,9 @@ function cmdRender(flags: Record<string, string>): void {
2045
2137
  // every slot says nothing and stays the bytes it always was (issue #835).
2046
2138
  ...subsetFields(subset),
2047
2139
  background: BACKGROUND,
2048
- viewport: {
2049
- x: viewport.minX,
2050
- y: viewport.minY,
2051
- width: viewport.maxX - viewport.minX,
2052
- height: viewport.maxY - viewport.minY,
2053
- scale: viewport.scale,
2054
- pixelWidth: viewport.width,
2055
- pixelHeight: viewport.height,
2056
- },
2140
+ // The one spelling `geometry.json` repeats, so the two files cannot state
2141
+ // two boxes for one grid.
2142
+ viewport: sidecarViewport(viewport),
2057
2143
  sets: [...sets].sort((a, b) => a.dir.localeCompare(b.dir)),
2058
2144
  };
2059
2145
  writeFileSync(join(outRoot, FRAMES_SIDECAR), `${JSON.stringify(sidecar, null, 2)}\n`);
@@ -3327,7 +3413,8 @@ function cmdExplain(flags: Record<string, string>): void {
3327
3413
  console.log(
3328
3414
  ` ${m.slot.padEnd(12)} ${m.kind.padEnd(8)} ${m.vertices} vertices / ${m.triangles} triangles ` +
3329
3415
  `${meshBudget(result.rig)} bones=[${m.bones.join(', ')}]${meshFit(m)}` +
3330
- meshDepthNote(m),
3416
+ meshDepthNote(m) +
3417
+ meshInfluenceNote(m),
3331
3418
  );
3332
3419
  }
3333
3420
  }
@@ -3737,6 +3824,10 @@ const FLAG_MEANINGS: Record<string, string> = {
3737
3824
  'one written is the smallest that holds the pack, spilling to more pages only when the set will not fit',
3738
3825
  padding: `gutter each region reserves on every side, --pack only (default ${DEFAULT_PADDING}); it is filled by ` +
3739
3826
  "extending the region's own edge pixels outwards, which is what stops a neighbour bleeding in",
3827
+ 'page-edges': `what a page's edges may be, --pack only (default ${DEFAULT_PAGE_EDGES}): pot is a power of two on ` +
3828
+ `both; free tries every width on a ${FREE_EDGE_STEP}px step, takes the height the placement needs and keeps ` +
3829
+ 'the least area — a smaller page, at the cost of region attachments sampling within 1 LSB of the loose ' +
3830
+ 'build rather than exactly',
3740
3831
  'atlas-in':
3741
3832
  'resolve every part against the regions of this pre-packed .atlas instead of against loose PNGs — region ' +
3742
3833
  'geometry (bounds/offsets/rotate) is read from the file and the atlas is re-emitted into --out, re-anchored',
@@ -3753,6 +3844,10 @@ const FLAG_MEANINGS: Record<string, string> = {
3753
3844
  'derived: a candidate is entitled to its own vocabulary, so a mapping worked out here would be a guess reported ' +
3754
3845
  'as a measurement',
3755
3846
  'all-bones': 'print every bone pair, not just the worst by position',
3847
+ geometry:
3848
+ `also write ${GEOMETRY_FILE} into each frame directory: per frame, every bone's world transform and every ` +
3849
+ "slot's region or mesh vertices in world units after skinning, plus each attachment's rest geometry — on the " +
3850
+ 'frames\' own grid and viewport. Not with --slot/--hide: the geometry is the whole pose whatever is drawn',
3756
3851
  'texture-from':
3757
3852
  "also measure this run through this atlas's texels, keeping the candidate's own geometry, and report how much " +
3758
3853
  'of the MAE is texture resampling rather than the rig — pass the atlas the reference frames were rendered ' +
@@ -3855,6 +3950,7 @@ const FLAG_VALUES: Record<string, string> = {
3855
3950
  'atlas-in': '<file.atlas>',
3856
3951
  'page-size': '<px>',
3857
3952
  padding: '<px>',
3953
+ 'page-edges': 'pot|free',
3858
3954
  'texture-from': '<path>',
3859
3955
  reference: '<dir|skeleton.json>',
3860
3956
  'reference-atlas': '<path>',
@@ -3941,7 +4037,7 @@ const COMMANDS: CommandDoc[] = [
3941
4037
  name: 'build',
3942
4038
  usage: [
3943
4039
  'rigc build --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--profile spine|spine-html] [--copy-images]',
3944
- `rigc build … --pack [--page-size ${DEFAULT_PAGE_SIZE}] [--padding ${DEFAULT_PADDING}] (parts onto shared pages, written into --out)`,
4040
+ `rigc build … --pack [--page-size ${DEFAULT_PAGE_SIZE}] [--padding ${DEFAULT_PADDING}] [--page-edges pot|free] (parts onto shared pages, written into --out)`,
3945
4041
  'rigc build … --atlas-in <skeleton.atlas> (resolve the parts against a pack somebody already made)',
3946
4042
  'rigc build --cut <name> --cuts <cuts.json>',
3947
4043
  ],
@@ -3955,6 +4051,7 @@ const COMMANDS: CommandDoc[] = [
3955
4051
  'pack',
3956
4052
  'page-size',
3957
4053
  'padding',
4054
+ 'page-edges',
3958
4055
  'atlas-in',
3959
4056
  'cut',
3960
4057
  'cuts',
@@ -3973,7 +4070,7 @@ const COMMANDS: CommandDoc[] = [
3973
4070
  'this line said "the same arguments as build, minus --profile" and was false in both',
3974
4071
  'directions (issue #697): --atlas-in was not listed here, so the one flag that lets this',
3975
4072
  'command read what `ingest --art none` writes was reachable and undocumented, while',
3976
- '--pack, --page-size, --padding and --copy-images are build\'s and do nothing here —',
4073
+ '--pack, --page-size, --padding, --page-edges and --copy-images are build\'s and do nothing here —',
3977
4074
  'they decide what is WRITTEN, and this command writes nothing. What it takes is listed',
3978
4075
  'above, and that is now the whole of it.',
3979
4076
  ],
@@ -4091,10 +4188,10 @@ const COMMANDS: CommandDoc[] = [
4091
4188
  {
4092
4189
  name: 'render',
4093
4190
  usage: [
4094
- 'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--out render/]',
4191
+ 'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--geometry] [--out render/]',
4095
4192
  'rigc render … --slot <name[,name…]> | --hide <name[,name…]> (a subset of the slots, on the whole rig\'s grid)',
4096
4193
  ],
4097
- flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'out'],
4194
+ flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'geometry', 'out'],
4098
4195
  overrides: {
4099
4196
  out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
4100
4197
  fps: { meaning: `frames per second to sample the animation at (default ${PROTOCOL_FPS})` },
@@ -4442,6 +4539,13 @@ try {
4442
4539
  // the one reader's and already names the file, what it is and what rigc
4443
4540
  // reads; a stack under it is the tool describing its own internals instead.
4444
4541
  // Exit 1, like a compile error: the invocation was fine, a file was not.
4542
+ // A pose the geometry export cannot write as numbers (issue #864): the
4543
+ // invocation was fine and the skeleton posed a NaN or an infinity, so exit 1
4544
+ // like a file that is not a PNG. Raised before the first file is written.
4545
+ if (err instanceof GeometryError) {
4546
+ console.error(`rigc render: ${err.message}`);
4547
+ process.exit(1);
4548
+ }
4445
4549
  if (err instanceof NotAPngError) {
4446
4550
  console.error(`rigc: ${err.message}`);
4447
4551
  process.exit(1);