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/README.md +37 -5
- package/cli.ts +562 -48
- package/docs/AUTHORING.md +156 -7
- package/docs/MOTION.md +1 -0
- package/package.json +1 -1
- package/skills/rigc/SKILL.md +42 -12
- package/skills/{face → rigc-face}/SKILL.md +11 -7
- package/skills/{ingest → rigc-ingest}/SKILL.md +10 -6
- package/skills/{motion → rigc-motion}/SKILL.md +11 -7
- package/skills/{rigging → rigc-rigging}/SKILL.md +11 -7
- package/src/ballot.ts +9 -12
- package/src/check.ts +181 -5
- package/src/checkpics.ts +293 -0
- package/src/preview.ts +223 -32
- package/src/render.ts +140 -2
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 <dir></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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
1097
|
-
: data.animations.map((a) => sampleAnimation(data, a.name, FRAMING_FPS,
|
|
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;
|