spine-rigc 1.0.2 → 1.2.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/src/preview.ts CHANGED
@@ -83,8 +83,123 @@ export interface PreviewInput {
83
83
  animations: string[];
84
84
  /** What the page calls itself — the skeleton's path, for the tab and the header. */
85
85
  label: string;
86
- /** rigc's own version, for the generated-by line. */
86
+ /** rigc's own version, for the generated-by line and the header's gate line. */
87
87
  version: string;
88
+ /** What the gate said about this candidate on THIS run — see `PreviewGate`. */
89
+ gate: PreviewGate;
90
+ }
91
+
92
+ /**
93
+ * The gate's reading of a candidate, taken when the page is written (issue #837).
94
+ *
95
+ * ⭐ Both strings are the validator's own lines, never a paraphrase: `summary`
96
+ * is the `N assertions: …` line `rigc validate <dir>` prints last and `refusal`
97
+ * is its first `FAIL` line, each without the report's gutter. The caller
98
+ * measures them on the run that writes the page, because a figure carried over
99
+ * from the build would be a claim about bytes nothing here read — and a bare
100
+ * directory re-gated is NOT what `build` gated: with no rig spec and no second
101
+ * compile, `A09` and `A18` report SKIP there, so the two runs print different
102
+ * counts over the same files and only this one's belongs on this page.
103
+ *
104
+ * `refusal` is `null` on green. A refused candidate is still previewed —
105
+ * looking at a red build is what this command is for — and its header says so
106
+ * in the gate's words.
107
+ */
108
+ export interface PreviewGate {
109
+ summary: string;
110
+ refusal: string | null;
111
+ }
112
+
113
+ /** The header's gate line, as one element: which rigc, what ran, and what it said. */
114
+ export function gateLine(gate: PreviewGate, version: string): string {
115
+ const said =
116
+ `rigc ${escapeHtml(version)}, re-gated as <code>rigc validate &lt;dir&gt;</code> gates it: ${escapeHtml(gate.summary)}`;
117
+ return gate.refusal === null
118
+ ? `<span class="rigc-gate" data-gate="green">${said}</span>`
119
+ : `<span class="rigc-gate" data-gate="refused" style="opacity: 1; color: #7d1d1d; font-weight: 600">${said} — ` +
120
+ `refused: ${escapeHtml(gate.refusal)}</span>`;
121
+ }
122
+
123
+ /** What a header says its player is playing. */
124
+ function playedText(animation: string | null, animations: string[]): string {
125
+ return animation === null
126
+ ? 'no animation — the setup pose'
127
+ : `${escapeHtml(animation)}${animations.length > 1 ? ` (of ${animations.length}; pick another in the controls)` : ''}`;
128
+ }
129
+
130
+ /** The header over one player: the path, what it plays, and the gate's line. */
131
+ function headerHtml(input: PreviewInput): string {
132
+ return (
133
+ `<header><b>${escapeHtml(input.label)}</b> <span>— ${playedText(input.animation, input.animations)}</span>` +
134
+ `<br>${gateLine(input.gate, input.version)}</header>`
135
+ );
136
+ }
137
+
138
+ /**
139
+ * One player's config. `animation` is left off a skeleton with none rather than
140
+ * set to null: the player checks for the key's presence, and the setup pose
141
+ * held still is the honest picture of a rig that has no animation to play.
142
+ */
143
+ function previewConfig(input: PreviewInput): Record<string, unknown> {
144
+ const rawDataURIs: Record<string, string> = {
145
+ [SKELETON_KEY]: dataUri('application/json', input.skeletonText),
146
+ [ATLAS_KEY]: dataUri('text/plain', input.atlasText),
147
+ };
148
+ for (const page of input.pages) rawDataURIs[page.name] = dataUri('image/png', page.bytes);
149
+ const config: Record<string, unknown> = {
150
+ skeleton: SKELETON_KEY,
151
+ atlas: ATLAS_KEY,
152
+ rawDataURIs,
153
+ animations: input.animations,
154
+ showControls: true,
155
+ alpha: false,
156
+ backgroundColor: backgroundHex(),
157
+ };
158
+ if (input.animation !== null) config.animation = input.animation;
159
+ return config;
160
+ }
161
+
162
+ /**
163
+ * The licence paragraph, spelled once for the one-candidate page and the page
164
+ * of panes so the two cannot drift. The ballot keeps its own copy of the same
165
+ * words: its bytes are held by `vote`'s controls, not moved by this card.
166
+ */
167
+ const PLAYER_LICENCE = ` It plays them in the Spine Web Player, which is NOT embedded: the script and
168
+ stylesheet below are loaded from unpkg. The Spine Runtimes are Copyright (c)
169
+ 2013-2025 Esoteric Software LLC and are licensed under the Spine Runtimes
170
+ License Agreement — https://esotericsoftware.com/spine-runtimes-license — which
171
+ requires each user of a product integrating them to hold a Spine Editor
172
+ license. Nothing owned by Esoteric Software is redistributed by rigc.`;
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // panes — the layout `vote` built first, shared rather than copied
176
+ // ---------------------------------------------------------------------------
177
+
178
+ /**
179
+ * The grid panes sit in, as the style lines the ballot has always carried: one
180
+ * column per pane, one column on a narrow screen. `buildBallot` calls this too,
181
+ * so a ballot's bytes are what they were and the two pages cannot drift.
182
+ */
183
+ export function paneGridCss(count: number): string {
184
+ return ` #panes { display: grid; grid-template-columns: repeat(${count}, minmax(0, 1fr)); gap: 1px; background: rgba(0, 0, 0, 0.15); }
185
+ @media (max-width: 720px) { #panes { grid-template-columns: minmax(0, 1fr); } }
186
+ .pane { background: ${backgroundHex()}; display: flex; flex-direction: column; min-width: 0; }`;
187
+ }
188
+
189
+ /** The box a pane's player mounts in, as the ballot styles it. */
190
+ export const PANE_STAGE_CSS = ' .stage { height: 52vh; min-height: 260px; }';
191
+
192
+ /** One pane: a heading the caller writes, over the element its player mounts in. */
193
+ export function paneSection(stageId: string, heading: string): string {
194
+ return `<section class="pane">
195
+ ${heading}
196
+ <div class="stage" id="${stageId}"></div>
197
+ </section>`;
198
+ }
199
+
200
+ /** The element pane `i` (0-based) mounts its player in, numbered from 1 as the CLI numbers candidates. */
201
+ export function previewPaneId(i: number): string {
202
+ return `rigc-player-${i + 1}`;
88
203
  }
89
204
 
90
205
  /**
@@ -138,31 +253,8 @@ export function embeddedJson(value: unknown): string {
138
253
  * page it produces has no dependency of its own except the player URL above.
139
254
  */
140
255
  export function buildPreview(input: PreviewInput): string {
141
- const rawDataURIs: Record<string, string> = {
142
- [SKELETON_KEY]: dataUri('application/json', input.skeletonText),
143
- [ATLAS_KEY]: dataUri('text/plain', input.atlasText),
144
- };
145
- for (const page of input.pages) rawDataURIs[page.name] = dataUri('image/png', page.bytes);
146
-
147
- // `animation` is left off a skeleton with none rather than set to null: the
148
- // player checks for the key's presence, and the setup pose held still is the
149
- // honest picture of a rig that has no animation to play.
150
- const config: Record<string, unknown> = {
151
- skeleton: SKELETON_KEY,
152
- atlas: ATLAS_KEY,
153
- rawDataURIs,
154
- animations: input.animations,
155
- showControls: true,
156
- alpha: false,
157
- backgroundColor: backgroundHex(),
158
- };
159
- if (input.animation !== null) config.animation = input.animation;
160
-
256
+ const config = previewConfig(input);
161
257
  const label = escapeHtml(input.label);
162
- const played =
163
- input.animation === null
164
- ? 'no animation — the setup pose'
165
- : `${escapeHtml(input.animation)}${input.animations.length > 1 ? ` (of ${input.animations.length}; pick another in the controls)` : ''}`;
166
258
 
167
259
  return `<!doctype html>
168
260
  <html lang="en">
@@ -176,12 +268,7 @@ export function buildPreview(input: PreviewInput): string {
176
268
  The skeleton, the atlas and every atlas page are embedded in this file as data
177
269
  URIs, so it plays on its own with no server and no sibling files.
178
270
 
179
- It plays them in the Spine Web Player, which is NOT embedded: the script and
180
- stylesheet below are loaded from unpkg. The Spine Runtimes are Copyright (c)
181
- 2013-2025 Esoteric Software LLC and are licensed under the Spine Runtimes
182
- License Agreement — https://esotericsoftware.com/spine-runtimes-license — which
183
- requires each user of a product integrating them to hold a Spine Editor
184
- license. Nothing owned by Esoteric Software is redistributed by rigc.
271
+ ${PLAYER_LICENCE}
185
272
  -->
186
273
  <link rel="stylesheet" href="${PLAYER_STYLE_URL}">
187
274
  <style>
@@ -202,7 +289,7 @@ export function buildPreview(input: PreviewInput): string {
202
289
  </style>
203
290
  </head>
204
291
  <body>
205
- <header><b>${label}</b> <span>— ${played}</span></header>
292
+ ${headerHtml(input)}
206
293
  <div id="rigc-player"></div>
207
294
  <p id="rigc-status">loading the Spine Web Player…</p>
208
295
  <script src="${PLAYER_SCRIPT_URL}"></script>
@@ -241,3 +328,107 @@ export function buildPreview(input: PreviewInput): string {
241
328
  </html>
242
329
  `;
243
330
  }
331
+
332
+ /**
333
+ * Several candidates on one page, a pane per candidate in the order given
334
+ * (issue #837).
335
+ *
336
+ * ⭐ This page asks nothing, and that is the whole difference from the ballot
337
+ * whose layout it borrows. A ballot hides where each candidate came from
338
+ * because a voter who can see it is no longer comparing pictures; here the
339
+ * question is "show me these", so each pane is headed exactly as a
340
+ * one-candidate preview is — its path, what it plays, the gate's line for it —
341
+ * and each player offers every animation its own skeleton has. No manifest, no
342
+ * digests and no result form: there is no answer to record.
343
+ *
344
+ * Each pane plays its own candidate's animation (the one `--animation` names,
345
+ * or its own first). The ballot refuses two panes playing two animations
346
+ * because the labels are A and B and nothing would say so; here the header over
347
+ * each pane names what it plays.
348
+ */
349
+ export function buildPreviewPanes(inputs: PreviewInput[], version: string): string {
350
+ const configs = inputs.map((input) => previewConfig(input));
351
+ const panes = inputs.map((input, i) => paneSection(previewPaneId(i), headerHtml(input))).join('\n');
352
+
353
+ return `<!doctype html>
354
+ <html lang="en">
355
+ <head>
356
+ <meta charset="utf-8">
357
+ <meta name="viewport" content="width=device-width, initial-scale=1">
358
+ <title>rigc preview — ${inputs.length} candidates</title>
359
+ <!--
360
+ Generated by rigc ${escapeHtml(version)} — https://github.com/firejune/rigc
361
+
362
+ Every candidate's skeleton, atlas and atlas pages are embedded in this file as
363
+ data URIs, so it plays on its own with no server and no sibling files.
364
+
365
+ ${PLAYER_LICENCE}
366
+ -->
367
+ <link rel="stylesheet" href="${PLAYER_STYLE_URL}">
368
+ <style>
369
+ :root { color-scheme: light dark; }
370
+ html, body { margin: 0; min-height: 100%; }
371
+ body {
372
+ background: ${backgroundHex()};
373
+ color: #1a1a1a;
374
+ font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
375
+ }
376
+ header { padding: 10px 14px; border-bottom: 1px solid rgba(0, 0, 0, 0.15); overflow-wrap: anywhere; }
377
+ header b { font-weight: 600; }
378
+ header span { opacity: 0.65; }
379
+ ${paneGridCss(inputs.length)}
380
+ ${PANE_STAGE_CSS}
381
+ #rigc-status { margin: 0; padding: 10px 14px; border-top: 1px solid rgba(0, 0, 0, 0.15); white-space: pre-wrap; }
382
+ #rigc-status[data-state="error"] { background: #7d1d1d; color: #fff; }
383
+ </style>
384
+ </head>
385
+ <body>
386
+ <div id="panes">
387
+ ${panes}
388
+ </div>
389
+ <p id="rigc-status">loading the Spine Web Player…</p>
390
+ <script src="${PLAYER_SCRIPT_URL}"></script>
391
+ <script>
392
+ (function () {
393
+ var configs = ${embeddedJson(configs)};
394
+ var status = document.getElementById('rigc-status');
395
+ var state = { status: 'loading', message: null, players: [], ready: [], failed: [] };
396
+ window.rigcPreview = state;
397
+ function say(kind, message) {
398
+ state.status = kind;
399
+ state.message = message;
400
+ status.textContent = message;
401
+ status.setAttribute('data-state', kind);
402
+ }
403
+ if (typeof window.spine === 'undefined' || typeof window.spine.SpinePlayer !== 'function') {
404
+ say('error', 'The Spine Web Player did not load from ${PLAYER_SCRIPT_URL} — this page needs a network connection the first time it is opened.');
405
+ return;
406
+ }
407
+ for (var i = 0; i < configs.length; i++) {
408
+ (function (pane, config) {
409
+ config.success = function (player) {
410
+ state.players[pane - 1] = player;
411
+ if (state.ready.indexOf(pane) === -1) state.ready.push(pane);
412
+ if (state.failed.length === 0 && state.ready.length === configs.length) {
413
+ say('ready', 'all ' + configs.length + ' panes are playing in Spine Web Player ${PLAYER_LINE} — everything they draw is embedded in this file.');
414
+ }
415
+ };
416
+ config.error = function (player, message) {
417
+ state.players[pane - 1] = player;
418
+ state.failed.push('pane ' + pane + ': ' + String(message));
419
+ say('error', state.failed.join('\\n'));
420
+ };
421
+ try {
422
+ new window.spine.SpinePlayer('rigc-player-' + pane, config);
423
+ } catch (err) {
424
+ state.failed.push('pane ' + pane + ': ' + String(err && err.message ? err.message : err));
425
+ say('error', state.failed.join('\\n'));
426
+ }
427
+ })(i + 1, configs[i]);
428
+ }
429
+ })();
430
+ </script>
431
+ </body>
432
+ </html>
433
+ `;
434
+ }
package/src/render.ts CHANGED
@@ -198,6 +198,19 @@ export interface FramesSidecar {
198
198
  * one it cannot (`skin` absent while the run asked for one).
199
199
  */
200
200
  skin?: string;
201
+ /**
202
+ * The slots these frames draw, when `render --slot` narrowed them to a subset
203
+ * (issue #835) — in the skeleton's draw order, whatever order they were named in.
204
+ *
205
+ * ⭐ Absent on a render of every slot, for the reason `skin` is: that is what
206
+ * every frame set written before this field existed says too, so the whole-rig
207
+ * render stays byte-identical and the key's presence is the claim. A frame set
208
+ * carrying this or `hidden` is a picture of PART of the rig, and `check` refuses
209
+ * it as a reference by name rather than scoring a whole candidate against it.
210
+ */
211
+ slots?: string[];
212
+ /** The slots these frames leave out, when `render --hide` named them — see `slots`. */
213
+ hidden?: string[];
201
214
  /** The colour the frames were cleared to, straight RGBA 0..255. */
202
215
  background: RGBA;
203
216
  viewport: {
@@ -329,6 +342,118 @@ export interface PoseOptions {
329
342
  * whole flag exists to remove.
330
343
  */
331
344
  skin?: string;
345
+ /**
346
+ * Draw only these slots, by name (issue #835). `hidden` is the same statement
347
+ * the other way round, and the two together are refused.
348
+ *
349
+ * ⭐ **It is a filter on what is DRAWN, never on what is framed.**
350
+ * `framingViewport` takes both off before it samples, so a frame with `head`
351
+ * hidden sits on exactly the pixel grid of the frame with it and the two
352
+ * overlay — which is the whole use of the picture: *which part is this pixel*
353
+ * is answered by the difference between two frames of one grid, and a subset
354
+ * re-framed to its own extent would have no second frame to differ from.
355
+ *
356
+ * ⚠️ Applied in `piecesOf`, where the pieces are collected, and resolved there
357
+ * against the posed skeleton's own slots and skin — see `slotSubsetOf` — so a
358
+ * name that draws nothing is refused by name rather than quietly matching no
359
+ * piece.
360
+ */
361
+ slots?: string[];
362
+ /** Draw every slot but these — see `slots`. */
363
+ hidden?: string[];
364
+ }
365
+
366
+ /**
367
+ * Why a slot subset cannot be drawn — a `--slot`/`--hide` naming no slot, a slot
368
+ * whose art only another skin carries, or both flags at once (issue #835).
369
+ *
370
+ * A class of its own so `cli.ts` can turn it into a usage refusal (exit 2,
371
+ * nothing written) without reading a message to decide what kind it is.
372
+ */
373
+ export class SlotSubsetError extends Error {}
374
+
375
+ /** The flag spelling each half of a subset is refused under — the UI's, since that is who reads it. */
376
+ const SUBSET_FLAG = { slots: '--slot', hidden: '--hide' } as const;
377
+
378
+ /**
379
+ * A slot subset resolved against a skeleton: which half was asked for, and the
380
+ * names in the skeleton's **draw order** rather than the order they were typed.
381
+ *
382
+ * Draw order because the names are a set and the sidecar records them: `--hide
383
+ * b,a` and `--hide a,b` are one picture, and a sidecar whose bytes depended on
384
+ * the spelling would make two identical frame sets differ.
385
+ */
386
+ export interface SlotSubset {
387
+ mode: 'slots' | 'hidden';
388
+ names: string[];
389
+ }
390
+
391
+ /**
392
+ * Resolve `slots` / `hidden` against `data` as posed under `skin`, or refuse by name.
393
+ *
394
+ * `undefined` when neither is set — the whole rig, which is the ordinary case and
395
+ * costs nothing. Refused, each naming what would have worked:
396
+ *
397
+ * - **both at once** — one statement two ways, as `--rig` with `--cut` is;
398
+ * - **a name the skeleton does not declare** — with every slot it does declare,
399
+ * in draw order, and how many;
400
+ * - **a slot whose attachments live only under skins this pose does not
401
+ * resolve through.** Every slot is declared at the skeleton's top level, so
402
+ * "a slot only a named skin declares" is not a shape the format has — what a
403
+ * skin declares is the slot's ART. A pose resolves an attachment through the
404
+ * skin it was set to and then the default skin (`Skeleton.getAttachment`), so
405
+ * a slot none of whose attachments is in either of those draws nothing in
406
+ * every frame, and `--slot` on it would be a blank picture that looks like an
407
+ * answer. The refusal names the skin(s) that do carry it.
408
+ *
409
+ * ⚠️ A declared slot with no attachment in ANY skin is accepted: it draws
410
+ * nothing under every skin, so there is no skin to name and no picture of it
411
+ * that a different invocation would produce.
412
+ */
413
+ export function slotSubsetOf(
414
+ data: SkeletonData,
415
+ opts: Pick<PoseOptions, 'slots' | 'hidden'> | undefined,
416
+ skin: string | undefined,
417
+ ): SlotSubset | undefined {
418
+ if (opts?.slots !== undefined && opts.hidden !== undefined) {
419
+ throw new SlotSubsetError(
420
+ '--slot and --hide are one statement two ways; name the slots to draw or the slots to hide, not both',
421
+ );
422
+ }
423
+ const mode = opts?.slots !== undefined ? 'slots' : opts?.hidden !== undefined ? 'hidden' : undefined;
424
+ if (mode === undefined) return undefined;
425
+ const asked = (mode === 'slots' ? opts?.slots : opts?.hidden) ?? [];
426
+ const flag = SUBSET_FLAG[mode];
427
+ const declared = data.slots.map((slot) => slot.name);
428
+
429
+ const unknown = asked.filter((name) => data.findSlot(name) === null);
430
+ if (unknown.length > 0 || asked.length === 0) {
431
+ const named =
432
+ unknown.length === 0
433
+ ? 'was given no slot name'
434
+ : `${unknown.map((name) => JSON.stringify(name)).join(', ')} ${unknown.length === 1 ? 'names' : 'name'} no slot`;
435
+ throw new SlotSubsetError(
436
+ `${flag} ${named}; this skeleton declares, in draw order: ${declared.join(', ') || 'none'} (${declared.length})`,
437
+ );
438
+ }
439
+
440
+ const resolving = new Set([skin ?? null, data.defaultSkin?.name ?? null]);
441
+ const underThisPose =
442
+ skin === undefined ? 'under no skin (the default skin alone)' : `under skin ${JSON.stringify(skin)}`;
443
+ for (const name of asked) {
444
+ const index = data.findSlot(name)?.index ?? -1;
445
+ const carriers = data.skins.filter((s) => s.getAttachments().some((entry) => entry.slotIndex === index));
446
+ if (carriers.length === 0 || carriers.some((s) => resolving.has(s.name))) continue;
447
+ const skins = carriers.map((s) => JSON.stringify(s.name));
448
+ throw new SlotSubsetError(
449
+ `${flag} ${JSON.stringify(name)} draws nothing ${underThisPose}: its attachments are declared only under ` +
450
+ `${skins.length === 1 ? 'skin' : 'skins'} ${skins.join(', ')} — pass --skin ${
451
+ skins.length === 1 ? skins[0] : 'with one of them'
452
+ }`,
453
+ );
454
+ }
455
+ const chosen = new Set(asked);
456
+ return { mode, names: declared.filter((name) => chosen.has(name)) };
332
457
  }
333
458
 
334
459
  /**
@@ -646,8 +771,14 @@ export function piecesOf(skeleton: Skeleton, opts?: PoseOptions): Piece[] {
646
771
  'skeleton.setSkin(...) and skeleton.setupPose() before this.',
647
772
  );
648
773
  }
774
+ // Resolved against the skeleton as it was posed — its own slots and the skin
775
+ // it was set to — so a name that draws nothing is refused here, where the one
776
+ // application point is, rather than matching no piece in silence.
777
+ const subset = slotSubsetOf(skeleton.data, opts, skeleton.skin?.name);
778
+ const named = subset === undefined ? undefined : new Set(subset.names);
649
779
  const pieces: Piece[] = [];
650
780
  for (const slot of skeleton.drawOrder.appliedPose) {
781
+ if (subset !== undefined && named !== undefined && named.has(slot.data.name) !== (subset.mode === 'slots')) continue;
651
782
  const pose = slot.appliedPose;
652
783
  const attachment = pose.attachment;
653
784
  if (!attachment) continue;
@@ -1091,10 +1222,17 @@ export function framingViewport(data: SkeletonData, maxSide: number, opts?: Pose
1091
1222
  // attachments that POSE, and two skins fill a slot with art of different sizes
1092
1223
  // in different places. Framing one skin's shot with another skin's box would
1093
1224
  // put the difference between two skins into every measurement taken in it.
1225
+ //
1226
+ // ⭐ A slot subset is the opposite case, and is taken off (issue #835): what
1227
+ // `--slot`/`--hide` leave out still counts toward the box, so a frame with a
1228
+ // part hidden lands on the pixel grid of the frame with it and the two overlay.
1229
+ // A subset framed to its own extent would move every pixel it kept.
1230
+ const { slots: _drawn, hidden: _hidden, ...whole } = opts ?? {};
1231
+ const framed = opts === undefined ? undefined : whole;
1094
1232
  const sets =
1095
1233
  data.animations.length === 0
1096
- ? [sampleSetupPose(data, opts)]
1097
- : data.animations.map((a) => sampleAnimation(data, a.name, FRAMING_FPS, opts));
1234
+ ? [sampleSetupPose(data, framed)]
1235
+ : data.animations.map((a) => sampleAnimation(data, a.name, FRAMING_FPS, framed));
1098
1236
  const box = unionBounds(sets);
1099
1237
  if (!Number.isFinite(box.minX)) return null;
1100
1238
  const pad = Math.max(box.maxX - box.minX, box.maxY - box.minY) * PAD;