@rig-c/core 2.69.0 → 2.71.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 +29 -9
- package/bin/rigc.cjs +2 -2
- package/docs/AUTHORING.md +279 -9
- package/package.json +5 -2
- package/skills/rigc/SKILL.md +1 -1
- package/skills/rigc-face/SKILL.md +1 -1
- package/skills/rigc-ingest/SKILL.md +1 -1
- package/skills/rigc-motion/SKILL.md +1 -1
- package/skills/rigc-rigging/SKILL.md +1 -1
- package/src/assertions/report.ts +1 -1
- package/src/atlas.ts +6 -1
- package/src/cli/core_commands.ts +68 -6
- package/src/cli/shared.ts +30 -2
- package/src/cli/write_whole.ts +1 -1
- package/src/compile.ts +48 -0
- package/src/core/animation.ts +56 -24
- package/src/core/index.ts +62 -1
- package/src/core/raw.ts +38 -12
- package/src/core/tracks.ts +23 -8
- package/src/core/vertices.ts +182 -44
- package/src/deformgen.ts +23 -7
- package/src/depth.ts +17 -4
- package/src/motion.ts +4 -4
- package/src/render_shared.ts +19 -0
- package/src/runtime/curves.ts +106 -0
- package/src/runtime/index.ts +80 -0
- package/src/runtime/model.ts +719 -0
- package/src/runtime/pose.ts +1014 -0
- package/src/seam.ts +6 -2
- package/src/sheet.ts +920 -0
- package/src/trackgen.ts +27 -4
package/src/sheet.ts
ADDED
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
import { Plate, type RGBA } from '../tools/plate.ts';
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_PADDING,
|
|
6
|
+
DEFAULT_PAGE_EDGES,
|
|
7
|
+
DEFAULT_PAGE_SIZE,
|
|
8
|
+
packAtlas,
|
|
9
|
+
type PackInput,
|
|
10
|
+
type PageEdges,
|
|
11
|
+
} from './atlas.ts';
|
|
12
|
+
import type { EnvironmentStep } from './environment.ts';
|
|
13
|
+
import { MODEL_DOCUMENT_FILE, spineFileSha256 } from './model.ts';
|
|
14
|
+
import {
|
|
15
|
+
projector,
|
|
16
|
+
renderFrame,
|
|
17
|
+
sidecarViewport,
|
|
18
|
+
TRANSPARENT_BACKGROUND,
|
|
19
|
+
type Frame,
|
|
20
|
+
type FrameSet,
|
|
21
|
+
type PinnedViewport,
|
|
22
|
+
type SlotSubset,
|
|
23
|
+
type Viewport,
|
|
24
|
+
} from './render_shared.ts';
|
|
25
|
+
|
|
26
|
+
/** Supported `--sheet-format` values on `rigc render --sheet`. */
|
|
27
|
+
export const SHEET_FORMATS = ['json-hash'] as const;
|
|
28
|
+
export type SheetFormat = (typeof SHEET_FORMATS)[number];
|
|
29
|
+
export const DEFAULT_SHEET_FORMAT: SheetFormat = 'json-hash';
|
|
30
|
+
|
|
31
|
+
/** File names and format tags written by `rigc render --sheet`. */
|
|
32
|
+
export const SHEET_PAGE_STEM = 'sheet';
|
|
33
|
+
export const SHEET_MANIFEST_FILE = 'sheet-manifest.json';
|
|
34
|
+
export const SHEET_MANIFEST_SPEC = 'rigc-sheet/1';
|
|
35
|
+
export const SHEET_PAGE_SIZE = DEFAULT_PAGE_SIZE;
|
|
36
|
+
export const SHEET_PADDING = DEFAULT_PADDING;
|
|
37
|
+
|
|
38
|
+
/** Refusal of a `--sheet` invocation whose frame dimensions or flags cannot be packed. */
|
|
39
|
+
export class SheetUsageError extends Error {
|
|
40
|
+
constructor(message: string) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = 'SheetUsageError';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Failure of the pre-write spritesheet self-check (`verifySheetPack`). */
|
|
47
|
+
export class SheetInvariantError extends Error {
|
|
48
|
+
constructor(message: string) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = 'SheetInvariantError';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One entry in a `sheet*.json` TexturePacker-style `frames` hash table. */
|
|
55
|
+
export interface SheetJsonFrameEntry {
|
|
56
|
+
frame: { x: number; y: number; w: number; h: number };
|
|
57
|
+
rotated: false;
|
|
58
|
+
trimmed: boolean;
|
|
59
|
+
spriteSourceSize: { x: number; y: number; w: number; h: number };
|
|
60
|
+
sourceSize: { w: number; h: number };
|
|
61
|
+
anchor: { x: number; y: number };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Per-page `sheet*.json` document (`json-hash`), shared by PixiJS `Spritesheet` and Phaser 3 `JSONHash`. */
|
|
65
|
+
export interface SheetJsonHashDocument {
|
|
66
|
+
frames: Record<string, SheetJsonFrameEntry>;
|
|
67
|
+
animations: Record<string, string[]>;
|
|
68
|
+
meta: {
|
|
69
|
+
app: 'rig-c';
|
|
70
|
+
format: 'RGBA8888';
|
|
71
|
+
image: string;
|
|
72
|
+
size: { w: number; h: number };
|
|
73
|
+
scale: '1';
|
|
74
|
+
related_multi_packs?: string[];
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Per-page summary in `sheet-manifest.json`. */
|
|
79
|
+
export interface SheetManifestPage {
|
|
80
|
+
index: number;
|
|
81
|
+
image: string;
|
|
82
|
+
table: string;
|
|
83
|
+
width: number;
|
|
84
|
+
height: number;
|
|
85
|
+
occupancy: number;
|
|
86
|
+
frames: string[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Per-frame record in `sheet-manifest.json`, ordered by chronological frame index `0..sampled - 1`. */
|
|
90
|
+
export interface SheetManifestFrame {
|
|
91
|
+
index: number;
|
|
92
|
+
key: string;
|
|
93
|
+
time: number;
|
|
94
|
+
page: number;
|
|
95
|
+
image: string;
|
|
96
|
+
table: string;
|
|
97
|
+
empty: boolean;
|
|
98
|
+
frame: { x: number; y: number; w: number; h: number };
|
|
99
|
+
spriteSourceSize: { x: number; y: number; w: number; h: number };
|
|
100
|
+
sourceSize: { w: number; h: number };
|
|
101
|
+
anchor: { x: number; y: number };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Source file identities recorded in `sheet-manifest.json` (`source`). */
|
|
105
|
+
export interface SheetManifestSource {
|
|
106
|
+
skeleton: { file: string; sha256: string };
|
|
107
|
+
model: { file: string; sha256: string } | null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The per-set `sheet-manifest.json` (`rigc-sheet/1`) document. */
|
|
111
|
+
export interface SheetManifest {
|
|
112
|
+
spec: typeof SHEET_MANIFEST_SPEC;
|
|
113
|
+
format: SheetFormat;
|
|
114
|
+
namespace: string;
|
|
115
|
+
source: SheetManifestSource;
|
|
116
|
+
animation: string | null;
|
|
117
|
+
skin?: string;
|
|
118
|
+
slots?: string[];
|
|
119
|
+
hidden?: string[];
|
|
120
|
+
environment?: EnvironmentStep[];
|
|
121
|
+
pinned?: PinnedViewport;
|
|
122
|
+
fps: number;
|
|
123
|
+
duration: number;
|
|
124
|
+
sampled: number;
|
|
125
|
+
endpointPolicy: 'inclusive-grid';
|
|
126
|
+
alphaMode: 'straight';
|
|
127
|
+
filterPolicy: {
|
|
128
|
+
recommended: 'linear';
|
|
129
|
+
paddingPx: number;
|
|
130
|
+
extrusion: 'clamp-edge';
|
|
131
|
+
};
|
|
132
|
+
pageEdges: PageEdges;
|
|
133
|
+
pageSize: number;
|
|
134
|
+
padding: number;
|
|
135
|
+
sourceSize: { w: number; h: number };
|
|
136
|
+
viewport: ReturnType<typeof sidecarViewport>;
|
|
137
|
+
pivot: {
|
|
138
|
+
world: [0, 0];
|
|
139
|
+
canvasPx: { x: number; y: number };
|
|
140
|
+
anchor: { x: number; y: number };
|
|
141
|
+
};
|
|
142
|
+
pages: SheetManifestPage[];
|
|
143
|
+
frames: SheetManifestFrame[];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** One packed page of a rendered set's spritesheet, ready to write after `verifySheetPack`. */
|
|
147
|
+
export interface SheetPackedPage {
|
|
148
|
+
index: number;
|
|
149
|
+
image: string;
|
|
150
|
+
table: string;
|
|
151
|
+
plate: Plate;
|
|
152
|
+
json: SheetJsonHashDocument;
|
|
153
|
+
jsonText: string;
|
|
154
|
+
occupancy: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Complete in-memory result for one animation set's spritesheet export. */
|
|
158
|
+
export interface SheetSetPack {
|
|
159
|
+
setName: string;
|
|
160
|
+
namespace: string;
|
|
161
|
+
format: SheetFormat;
|
|
162
|
+
pageEdges: PageEdges;
|
|
163
|
+
pages: SheetPackedPage[];
|
|
164
|
+
manifest: SheetManifest;
|
|
165
|
+
manifestText: string;
|
|
166
|
+
untrimmedPlates: Plate[];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Options for building one animation set's spritesheet pack. */
|
|
170
|
+
export interface BuildSheetSetOptions {
|
|
171
|
+
rigName: string;
|
|
172
|
+
setName: string;
|
|
173
|
+
animation: string | null;
|
|
174
|
+
fps: number;
|
|
175
|
+
maxSide: number;
|
|
176
|
+
viewport: Viewport;
|
|
177
|
+
frames: readonly Frame[];
|
|
178
|
+
atlasPages: Map<string, Plate>;
|
|
179
|
+
source?: SheetManifestSource;
|
|
180
|
+
format?: SheetFormat;
|
|
181
|
+
pageEdges?: PageEdges;
|
|
182
|
+
skin?: string;
|
|
183
|
+
subset?: SlotSubset;
|
|
184
|
+
environment?: EnvironmentStep[];
|
|
185
|
+
pinned?: PinnedViewport;
|
|
186
|
+
/** For negative-control testing in `selftest.ts` only: reverse the input order handed to `packAtlas`. */
|
|
187
|
+
reversePackInputs?: boolean;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Normalize `-0` to `0` so JSON serialization never carries sign noise. */
|
|
191
|
+
function cleanZero(n: number): number {
|
|
192
|
+
return n === 0 ? 0 : n;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Build the `source` identity block for `sheet-manifest.json`: relative filenames
|
|
197
|
+
* (no directory paths) and SHA-256 hex digests of the `skeleton.json` and
|
|
198
|
+
* `skeleton.model.json` the sheet was rendered from.
|
|
199
|
+
*/
|
|
200
|
+
export function sheetSourceOf(
|
|
201
|
+
skeletonPath: string,
|
|
202
|
+
skeletonText: string | Uint8Array,
|
|
203
|
+
modelText: string | Uint8Array | null,
|
|
204
|
+
): SheetManifestSource {
|
|
205
|
+
return {
|
|
206
|
+
skeleton: {
|
|
207
|
+
file: basename(skeletonPath),
|
|
208
|
+
sha256: spineFileSha256(skeletonText),
|
|
209
|
+
},
|
|
210
|
+
model:
|
|
211
|
+
modelText === null
|
|
212
|
+
? null
|
|
213
|
+
: {
|
|
214
|
+
file: MODEL_DOCUMENT_FILE,
|
|
215
|
+
sha256: spineFileSha256(modelText),
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Derive the deterministic `<rig>` identifier for frame keys from the compiled
|
|
222
|
+
* model document's `rig.archetype` (`buildRigInfo` in `src/compile.ts` sets
|
|
223
|
+
* `archetype: rig.name` directly from the rig spec's `name` field, and `RIG_FIELDS`
|
|
224
|
+
* in `src/assertions/model/declared.ts` holds it as a required string on every
|
|
225
|
+
* model document). For a foreign Spine
|
|
226
|
+
* export without `skeleton.model.json`, uses the skeleton file's stem when it is
|
|
227
|
+
* not `"skeleton"`, or a content-deterministic digest of `skeletonText` — never
|
|
228
|
+
* the build's parent directory name, so moving or copying a build folder never
|
|
229
|
+
* changes frame keys or emitted sheet bytes.
|
|
230
|
+
*/
|
|
231
|
+
export function sheetRigNameOf(
|
|
232
|
+
skeletonPath: string,
|
|
233
|
+
modelText: string | null,
|
|
234
|
+
skeletonText?: string | Uint8Array,
|
|
235
|
+
): string {
|
|
236
|
+
if (modelText !== null) {
|
|
237
|
+
try {
|
|
238
|
+
const parsed = JSON.parse(modelText) as {
|
|
239
|
+
rig?: { archetype?: unknown; name?: unknown; skeleton?: { name?: unknown } };
|
|
240
|
+
};
|
|
241
|
+
const name = parsed.rig?.archetype ?? parsed.rig?.name ?? parsed.rig?.skeleton?.name;
|
|
242
|
+
if (typeof name === 'string' && name.trim().length > 0) {
|
|
243
|
+
return sanitizeNamespaceSegment(name.trim());
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
// Fall back below when model JSON is unparseable.
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const stem = basename(skeletonPath, '.json');
|
|
250
|
+
if (stem !== 'skeleton' && stem.length > 0) {
|
|
251
|
+
return sanitizeNamespaceSegment(stem);
|
|
252
|
+
}
|
|
253
|
+
if (skeletonText !== undefined) {
|
|
254
|
+
return `skeleton-${spineFileSha256(skeletonText).slice(0, 8)}`;
|
|
255
|
+
}
|
|
256
|
+
return 'rig';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function sanitizeNamespaceSegment(segment: string): string {
|
|
260
|
+
return segment.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '') || 'rig';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Build the collision-free frame-key namespace `<rig>/<anim>[/<variant>]` for a
|
|
265
|
+
* rendered set. Non-default skin, slot subset, or environment schedule are
|
|
266
|
+
* encoded in `<variant>` in fixed order so multiple exports loaded into a shared
|
|
267
|
+
* texture cache cannot collide on frame keys.
|
|
268
|
+
*/
|
|
269
|
+
export function sheetNamespaceOf(opts: {
|
|
270
|
+
rigName: string;
|
|
271
|
+
setName: string;
|
|
272
|
+
skin?: string;
|
|
273
|
+
subset?: SlotSubset;
|
|
274
|
+
environment?: EnvironmentStep[];
|
|
275
|
+
}): string {
|
|
276
|
+
const rig = sanitizeNamespaceSegment(opts.rigName);
|
|
277
|
+
const anim = sanitizeNamespaceSegment(opts.setName);
|
|
278
|
+
const variants: string[] = [];
|
|
279
|
+
if (opts.skin !== undefined) variants.push(`skin=${opts.skin}`);
|
|
280
|
+
if (opts.subset !== undefined) variants.push(`${opts.subset.mode}=${opts.subset.names.join(',')}`);
|
|
281
|
+
if (opts.environment !== undefined) {
|
|
282
|
+
const digest = createHash('sha256').update(JSON.stringify(opts.environment)).digest('hex').slice(0, 8);
|
|
283
|
+
variants.push(`env=${digest}`);
|
|
284
|
+
}
|
|
285
|
+
return variants.length === 0 ? `${rig}/${anim}` : `${rig}/${anim}/${variants.join('+')}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Zero-padded frame key `<namespace>/f<NNNN>` in chronological order. */
|
|
289
|
+
export function sheetFrameKey(namespace: string, index: number): string {
|
|
290
|
+
return `${namespace}/f${String(index).padStart(4, '0')}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Result of alpha-byte `> 0` minimal bounding-box trimming on one rendered frame. */
|
|
294
|
+
export interface TrimmedFrame {
|
|
295
|
+
plate: Plate;
|
|
296
|
+
trimX: number;
|
|
297
|
+
trimY: number;
|
|
298
|
+
width: number;
|
|
299
|
+
height: number;
|
|
300
|
+
trimmed: boolean;
|
|
301
|
+
empty: boolean;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Compute the minimal bounding box of pixels with `alpha > 0` (straight RGBA8
|
|
306
|
+
* alpha byte `>= 1`).
|
|
307
|
+
*
|
|
308
|
+
* When every pixel has `alpha === 0`, returns a `1x1` transparent `[0, 0, 0, 0]`
|
|
309
|
+
* sentinel at `(0, 0)` with `empty: true` and `trimmed: true` so the frame's
|
|
310
|
+
* index, timestamp, and original `sourceSize` stay intact in the animation
|
|
311
|
+
* sequence without wasting atlas area.
|
|
312
|
+
*/
|
|
313
|
+
export function trimTransparentFrame(source: Plate): TrimmedFrame {
|
|
314
|
+
const sw = source.width;
|
|
315
|
+
const sh = source.height;
|
|
316
|
+
const data = source.data;
|
|
317
|
+
let minX = sw;
|
|
318
|
+
let minY = sh;
|
|
319
|
+
let maxX = -1;
|
|
320
|
+
let maxY = -1;
|
|
321
|
+
|
|
322
|
+
for (let y = 0; y < sh; y++) {
|
|
323
|
+
const rowOffset = y * sw * 4;
|
|
324
|
+
for (let x = 0; x < sw; x++) {
|
|
325
|
+
if (data[rowOffset + x * 4 + 3] > 0) {
|
|
326
|
+
if (x < minX) minX = x;
|
|
327
|
+
if (x > maxX) maxX = x;
|
|
328
|
+
if (y < minY) minY = y;
|
|
329
|
+
if (y > maxY) maxY = y;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (maxX < minX || maxY < minY) {
|
|
335
|
+
const sentinel = new Plate(1, 1);
|
|
336
|
+
return {
|
|
337
|
+
plate: sentinel,
|
|
338
|
+
trimX: 0,
|
|
339
|
+
trimY: 0,
|
|
340
|
+
width: 1,
|
|
341
|
+
height: 1,
|
|
342
|
+
trimmed: true,
|
|
343
|
+
empty: true,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const width = maxX - minX + 1;
|
|
348
|
+
const height = maxY - minY + 1;
|
|
349
|
+
const trimmed = minX !== 0 || minY !== 0 || width !== sw || height !== sh;
|
|
350
|
+
const cropped = new Plate(width, height);
|
|
351
|
+
for (let y = 0; y < height; y++) {
|
|
352
|
+
const srcStart = ((minY + y) * sw + minX) * 4;
|
|
353
|
+
const dstStart = y * width * 4;
|
|
354
|
+
cropped.data.set(data.subarray(srcStart, srcStart + width * 4), dstStart);
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
plate: cropped,
|
|
358
|
+
trimX: minX,
|
|
359
|
+
trimY: minY,
|
|
360
|
+
width,
|
|
361
|
+
height,
|
|
362
|
+
trimmed,
|
|
363
|
+
empty: false,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Project world origin `(0, 0)` into the untrimmed `viewport` canvas and return
|
|
369
|
+
* both its pixel coordinates and normalized `anchor` (`{ x, y }`).
|
|
370
|
+
*
|
|
371
|
+
* Values are never clamped to `[0, 1]`, so a viewport that does not contain
|
|
372
|
+
* world `(0, 0)` still places the rig's world origin at the exact same point.
|
|
373
|
+
*/
|
|
374
|
+
export function sheetPivotOf(viewport: Viewport): {
|
|
375
|
+
canvasPx: { x: number; y: number };
|
|
376
|
+
anchor: { x: number; y: number };
|
|
377
|
+
} {
|
|
378
|
+
const [px, py] = projector(viewport)(0, 0);
|
|
379
|
+
const canvasX = cleanZero(px);
|
|
380
|
+
const canvasY = cleanZero(py);
|
|
381
|
+
return {
|
|
382
|
+
canvasPx: { x: canvasX, y: canvasY },
|
|
383
|
+
anchor: {
|
|
384
|
+
x: cleanZero(canvasX / viewport.width),
|
|
385
|
+
y: cleanZero(canvasY / viewport.height),
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Reconstruct the full `sourceSize.w x sourceSize.h` transparent frame `Plate`
|
|
392
|
+
* from a packed page `Plate` and a `SheetJsonFrameEntry`.
|
|
393
|
+
*/
|
|
394
|
+
export function reconstructSheetFrame(page: Plate, entry: SheetJsonFrameEntry): Plate {
|
|
395
|
+
const out = new Plate(entry.sourceSize.w, entry.sourceSize.h);
|
|
396
|
+
const { x: fx, y: fy, w: fw, h: fh } = entry.frame;
|
|
397
|
+
const { x: sx, y: sy } = entry.spriteSourceSize;
|
|
398
|
+
for (let dy = 0; dy < fh; dy++) {
|
|
399
|
+
const py = fy + dy;
|
|
400
|
+
const ty = sy + dy;
|
|
401
|
+
if (py < 0 || py >= page.height || ty < 0 || ty >= out.height) continue;
|
|
402
|
+
for (let dx = 0; dx < fw; dx++) {
|
|
403
|
+
const px = fx + dx;
|
|
404
|
+
const tx = sx + dx;
|
|
405
|
+
if (px < 0 || px >= page.width || tx < 0 || tx >= out.width) continue;
|
|
406
|
+
const srcIdx = (py * page.width + px) * 4;
|
|
407
|
+
const dstIdx = (ty * out.width + tx) * 4;
|
|
408
|
+
out.data[dstIdx] = page.data[srcIdx];
|
|
409
|
+
out.data[dstIdx + 1] = page.data[srcIdx + 1];
|
|
410
|
+
out.data[dstIdx + 2] = page.data[srcIdx + 2];
|
|
411
|
+
out.data[dstIdx + 3] = page.data[srcIdx + 3];
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return out;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function isPowerOfTwo(n: number): boolean {
|
|
418
|
+
return Number.isInteger(n) && n >= 1 && (n & (n - 1)) === 0;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function rgbaString(data: Uint8Array, idx: number): string {
|
|
422
|
+
return `[${data[idx]}, ${data[idx + 1]}, ${data[idx + 2]}, ${data[idx + 3]}]`;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Verify every structural, packing, extrusion, ordering, and pixel-reconstruction
|
|
427
|
+
* invariant of a packed spritesheet set against the expected untrimmed transparent
|
|
428
|
+
* frames. Returns a list of named faults (empty when green).
|
|
429
|
+
*/
|
|
430
|
+
export function sheetPackFaults(
|
|
431
|
+
pages: ReadonlyArray<{ image: string; table: string; plate: Plate; json: SheetJsonHashDocument }>,
|
|
432
|
+
manifest: SheetManifest,
|
|
433
|
+
expectedUntrimmed: readonly Plate[],
|
|
434
|
+
): string[] {
|
|
435
|
+
const faults: string[] = [];
|
|
436
|
+
if (manifest.spec !== SHEET_MANIFEST_SPEC) {
|
|
437
|
+
faults.push(`manifest.spec is ${JSON.stringify(manifest.spec)}, required ${JSON.stringify(SHEET_MANIFEST_SPEC)}`);
|
|
438
|
+
}
|
|
439
|
+
if (manifest.format !== 'json-hash') {
|
|
440
|
+
faults.push(`manifest.format is ${JSON.stringify(manifest.format)}, required "json-hash"`);
|
|
441
|
+
}
|
|
442
|
+
if (manifest.alphaMode !== 'straight') {
|
|
443
|
+
faults.push(`manifest.alphaMode is ${JSON.stringify(manifest.alphaMode)}, required "straight"`);
|
|
444
|
+
}
|
|
445
|
+
if (manifest.endpointPolicy !== 'inclusive-grid') {
|
|
446
|
+
faults.push(`manifest.endpointPolicy is ${JSON.stringify(manifest.endpointPolicy)}, required "inclusive-grid"`);
|
|
447
|
+
}
|
|
448
|
+
const hex64 = /^[0-9a-f]{64}$/;
|
|
449
|
+
const skelSrc = manifest.source?.skeleton;
|
|
450
|
+
if (
|
|
451
|
+
skelSrc === undefined ||
|
|
452
|
+
typeof skelSrc.file !== 'string' ||
|
|
453
|
+
skelSrc.file.length === 0 ||
|
|
454
|
+
skelSrc.file.includes('/') ||
|
|
455
|
+
skelSrc.file.includes('\\') ||
|
|
456
|
+
typeof skelSrc.sha256 !== 'string' ||
|
|
457
|
+
!hex64.test(skelSrc.sha256)
|
|
458
|
+
) {
|
|
459
|
+
faults.push(
|
|
460
|
+
`manifest.source.skeleton is ${JSON.stringify(skelSrc ?? null)}, required { file: <relative name>, sha256: <64-hex> }`,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
const modelSrc = manifest.source?.model;
|
|
464
|
+
if (
|
|
465
|
+
modelSrc !== null &&
|
|
466
|
+
(modelSrc === undefined ||
|
|
467
|
+
modelSrc.file !== MODEL_DOCUMENT_FILE ||
|
|
468
|
+
modelSrc.file.includes('/') ||
|
|
469
|
+
modelSrc.file.includes('\\') ||
|
|
470
|
+
typeof modelSrc.sha256 !== 'string' ||
|
|
471
|
+
!hex64.test(modelSrc.sha256))
|
|
472
|
+
) {
|
|
473
|
+
faults.push(
|
|
474
|
+
`manifest.source.model is ${JSON.stringify(modelSrc)}, required null or { file: ${JSON.stringify(MODEL_DOCUMENT_FILE)}, sha256: <64-hex> }`,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
if (pages.length === 0) {
|
|
478
|
+
faults.push('sheet pack has 0 pages');
|
|
479
|
+
return faults;
|
|
480
|
+
}
|
|
481
|
+
if (manifest.pages.length !== pages.length) {
|
|
482
|
+
faults.push(`manifest.pages has ${manifest.pages.length} page(s) where ${pages.length} page(s) were packed`);
|
|
483
|
+
}
|
|
484
|
+
if (manifest.frames.length !== expectedUntrimmed.length) {
|
|
485
|
+
faults.push(`manifest.frames has ${manifest.frames.length} frame(s), required ${expectedUntrimmed.length}`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const padding = manifest.padding;
|
|
489
|
+
const maxEdge = manifest.pageSize;
|
|
490
|
+
|
|
491
|
+
// Check each page's dimensions, metadata, chronological key order, cell bounds, non-overlap, and 2-px extrusion.
|
|
492
|
+
const seenInTables = new Map<string, { pageIndex: number; entry: SheetJsonFrameEntry }>();
|
|
493
|
+
for (let p = 0; p < pages.length; p++) {
|
|
494
|
+
const page = pages[p];
|
|
495
|
+
const pw = page.plate.width;
|
|
496
|
+
const ph = page.plate.height;
|
|
497
|
+
const metaSize = page.json.meta?.size as unknown;
|
|
498
|
+
if (
|
|
499
|
+
typeof metaSize !== 'object' ||
|
|
500
|
+
metaSize === null ||
|
|
501
|
+
Array.isArray(metaSize) ||
|
|
502
|
+
(metaSize as { w?: unknown }).w !== pw ||
|
|
503
|
+
(metaSize as { h?: unknown }).h !== ph
|
|
504
|
+
) {
|
|
505
|
+
faults.push(
|
|
506
|
+
`page ${p} (${page.table}): meta.size is ${JSON.stringify(metaSize)}, required single size object {"w":${pw},"h":${ph}}`,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
if (page.json.meta.image !== page.image) {
|
|
510
|
+
faults.push(`page ${p} (${page.table}): meta.image is ${JSON.stringify(page.json.meta.image)}, required ${JSON.stringify(page.image)}`);
|
|
511
|
+
}
|
|
512
|
+
if (pw > maxEdge || ph > maxEdge || pw < 1 || ph < 1) {
|
|
513
|
+
faults.push(`page ${p} (${page.image}): size ${pw}x${ph} exceeds max edge ${maxEdge}`);
|
|
514
|
+
}
|
|
515
|
+
if (manifest.pageEdges === 'pot' && (!isPowerOfTwo(pw) || !isPowerOfTwo(ph))) {
|
|
516
|
+
faults.push(`page ${p} (${page.image}): size ${pw}x${ph} is not a power of two under --page-edges pot`);
|
|
517
|
+
}
|
|
518
|
+
const wantRelated = pages.length > 1 ? pages.filter((_, i) => i !== p).map((other) => other.table) : undefined;
|
|
519
|
+
if (JSON.stringify(page.json.meta.related_multi_packs) !== JSON.stringify(wantRelated)) {
|
|
520
|
+
faults.push(
|
|
521
|
+
`page ${p} (${page.table}): meta.related_multi_packs is ${JSON.stringify(page.json.meta.related_multi_packs)}, required ${JSON.stringify(wantRelated)}`,
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const keys = Object.keys(page.json.frames);
|
|
526
|
+
const animKeys = page.json.animations[manifest.namespace] ?? [];
|
|
527
|
+
if (JSON.stringify(keys) !== JSON.stringify(animKeys)) {
|
|
528
|
+
faults.push(
|
|
529
|
+
`page ${p} (${page.table}): animations[${JSON.stringify(manifest.namespace)}] lists [${animKeys.join(', ')}], which differs from frames keys [${keys.join(', ')}]`,
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
const indices = keys.map((k) => {
|
|
533
|
+
const m = /\/f(\d+)$/.exec(k);
|
|
534
|
+
return m === null ? -1 : Number(m[1]);
|
|
535
|
+
});
|
|
536
|
+
for (let i = 1; i < indices.length; i++) {
|
|
537
|
+
if (indices[i] <= indices[i - 1]) {
|
|
538
|
+
faults.push(
|
|
539
|
+
`page ${p} (${page.table}): frame keys out of chronological order at ${JSON.stringify(keys[i - 1])} -> ${JSON.stringify(keys[i])}`,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const placedOnPage: Array<{ key: string; x: number; y: number; w: number; h: number }> = [];
|
|
545
|
+
for (const key of keys) {
|
|
546
|
+
const entry = page.json.frames[key];
|
|
547
|
+
if (seenInTables.has(key)) {
|
|
548
|
+
faults.push(`frame ${JSON.stringify(key)} appears on both page ${seenInTables.get(key)!.pageIndex} and page ${p}`);
|
|
549
|
+
}
|
|
550
|
+
seenInTables.set(key, { pageIndex: p, entry });
|
|
551
|
+
const { x, y, w, h } = entry.frame;
|
|
552
|
+
if (x - padding < 0 || y - padding < 0 || x + w + padding > pw || y + h + padding > ph) {
|
|
553
|
+
faults.push(
|
|
554
|
+
`page ${p} (${page.image}) frame ${JSON.stringify(key)}: padded cell [${x - padding}, ${y - padding}, ${w + 2 * padding}, ${h + 2 * padding}] lies outside ${pw}x${ph} page`,
|
|
555
|
+
);
|
|
556
|
+
} else if (padding > 0) {
|
|
557
|
+
// Verify 2-px clamped edge extrusion around the placed rectangle.
|
|
558
|
+
let extrusionFault: string | null = null;
|
|
559
|
+
for (let py = y - padding; py < y + h + padding && extrusionFault === null; py++) {
|
|
560
|
+
for (let px = x - padding; px < x + w + padding; px++) {
|
|
561
|
+
if (px >= x && px < x + w && py >= y && py < y + h) continue;
|
|
562
|
+
const cx = Math.max(x, Math.min(x + w - 1, px));
|
|
563
|
+
const cy = Math.max(y, Math.min(y + h - 1, py));
|
|
564
|
+
const gotIdx = (py * pw + px) * 4;
|
|
565
|
+
const wantIdx = (cy * pw + cx) * 4;
|
|
566
|
+
if (
|
|
567
|
+
page.plate.data[gotIdx] !== page.plate.data[wantIdx] ||
|
|
568
|
+
page.plate.data[gotIdx + 1] !== page.plate.data[wantIdx + 1] ||
|
|
569
|
+
page.plate.data[gotIdx + 2] !== page.plate.data[wantIdx + 2] ||
|
|
570
|
+
page.plate.data[gotIdx + 3] !== page.plate.data[wantIdx + 3]
|
|
571
|
+
) {
|
|
572
|
+
extrusionFault =
|
|
573
|
+
`page ${p} (${page.image}) frame ${JSON.stringify(key)}: extruded gutter pixel (${px}, ${py}) is ` +
|
|
574
|
+
`${rgbaString(page.plate.data, gotIdx)}, required clamped edge (${cx}, ${cy}) ${rgbaString(page.plate.data, wantIdx)}`;
|
|
575
|
+
break;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (extrusionFault !== null) faults.push(extrusionFault);
|
|
580
|
+
}
|
|
581
|
+
placedOnPage.push({ key, x, y, w, h });
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Check pairwise non-overlap of padded cells on this page.
|
|
585
|
+
for (let i = 0; i < placedOnPage.length; i++) {
|
|
586
|
+
const a = placedOnPage[i];
|
|
587
|
+
const ax0 = a.x - padding;
|
|
588
|
+
const ay0 = a.y - padding;
|
|
589
|
+
const ax1 = a.x + a.w + padding;
|
|
590
|
+
const ay1 = a.y + a.h + padding;
|
|
591
|
+
for (let j = i + 1; j < placedOnPage.length; j++) {
|
|
592
|
+
const b = placedOnPage[j];
|
|
593
|
+
const bx0 = b.x - padding;
|
|
594
|
+
const by0 = b.y - padding;
|
|
595
|
+
const bx1 = b.x + b.w + padding;
|
|
596
|
+
const by1 = b.y + b.h + padding;
|
|
597
|
+
if (ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0) {
|
|
598
|
+
faults.push(
|
|
599
|
+
`page ${p} (${page.image}): padded cells overlap between ${JSON.stringify(a.key)} ([${ax0},${ay0}..${ax1},${ay1}]) and ${JSON.stringify(b.key)} ([${bx0},${by0}..${bx1},${by1}])`,
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Check chronological manifest frames and full-canvas RGBA reconstruction.
|
|
607
|
+
for (let i = 0; i < manifest.frames.length; i++) {
|
|
608
|
+
const mf = manifest.frames[i];
|
|
609
|
+
const wantKey = sheetFrameKey(manifest.namespace, i);
|
|
610
|
+
if (mf.index !== i || mf.key !== wantKey) {
|
|
611
|
+
faults.push(
|
|
612
|
+
`manifest.frames[${i}] has index ${mf.index} key ${JSON.stringify(mf.key)}, required index ${i} key ${JSON.stringify(wantKey)}`,
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
const found = seenInTables.get(mf.key);
|
|
616
|
+
if (found === undefined) {
|
|
617
|
+
faults.push(`manifest frame ${JSON.stringify(mf.key)} is missing from all sheet*.json tables`);
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
if (found.pageIndex !== mf.page) {
|
|
621
|
+
faults.push(`manifest frame ${JSON.stringify(mf.key)} states page ${mf.page}, found on page ${found.pageIndex}`);
|
|
622
|
+
}
|
|
623
|
+
const entry = found.entry;
|
|
624
|
+
if (
|
|
625
|
+
JSON.stringify(entry.frame) !== JSON.stringify(mf.frame) ||
|
|
626
|
+
JSON.stringify(entry.spriteSourceSize) !== JSON.stringify(mf.spriteSourceSize) ||
|
|
627
|
+
JSON.stringify(entry.sourceSize) !== JSON.stringify(mf.sourceSize) ||
|
|
628
|
+
JSON.stringify(entry.anchor) !== JSON.stringify(mf.anchor)
|
|
629
|
+
) {
|
|
630
|
+
faults.push(`manifest frame ${JSON.stringify(mf.key)} disagrees with ${pages[found.pageIndex].table}`);
|
|
631
|
+
}
|
|
632
|
+
if (
|
|
633
|
+
entry.spriteSourceSize.x < 0 ||
|
|
634
|
+
entry.spriteSourceSize.y < 0 ||
|
|
635
|
+
entry.spriteSourceSize.w !== entry.frame.w ||
|
|
636
|
+
entry.spriteSourceSize.h !== entry.frame.h ||
|
|
637
|
+
entry.spriteSourceSize.x + entry.spriteSourceSize.w > entry.sourceSize.w ||
|
|
638
|
+
entry.spriteSourceSize.y + entry.spriteSourceSize.h > entry.sourceSize.h
|
|
639
|
+
) {
|
|
640
|
+
faults.push(
|
|
641
|
+
`frame ${JSON.stringify(mf.key)}: spriteSourceSize ${JSON.stringify(entry.spriteSourceSize)} is incompatible with frame ${JSON.stringify(entry.frame)} and sourceSize ${JSON.stringify(entry.sourceSize)}`,
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
const pivotErrX = Math.abs(entry.anchor.x * entry.sourceSize.w - manifest.pivot.canvasPx.x);
|
|
645
|
+
const pivotErrY = Math.abs(entry.anchor.y * entry.sourceSize.h - manifest.pivot.canvasPx.y);
|
|
646
|
+
if (pivotErrX > 1e-6 || pivotErrY > 1e-6) {
|
|
647
|
+
faults.push(
|
|
648
|
+
`frame ${JSON.stringify(mf.key)}: anchor (${entry.anchor.x}, ${entry.anchor.y}) * sourceSize (${entry.sourceSize.w}x${entry.sourceSize.h}) drifts from world-(0,0) canvasPx (${manifest.pivot.canvasPx.x}, ${manifest.pivot.canvasPx.y}) by (${pivotErrX}, ${pivotErrY})`,
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const expected = expectedUntrimmed[i];
|
|
653
|
+
if (expected === undefined) continue;
|
|
654
|
+
if (entry.sourceSize.w !== expected.width || entry.sourceSize.h !== expected.height) {
|
|
655
|
+
faults.push(
|
|
656
|
+
`frame ${JSON.stringify(mf.key)}: sourceSize ${entry.sourceSize.w}x${entry.sourceSize.h} differs from rendered canvas ${expected.width}x${expected.height}`,
|
|
657
|
+
);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
const reconstructed = reconstructSheetFrame(pages[found.pageIndex].plate, entry);
|
|
661
|
+
for (let y = 0; y < expected.height; y++) {
|
|
662
|
+
let pixelFault: string | null = null;
|
|
663
|
+
for (let x = 0; x < expected.width; x++) {
|
|
664
|
+
const idx = (y * expected.width + x) * 4;
|
|
665
|
+
if (
|
|
666
|
+
reconstructed.data[idx] !== expected.data[idx] ||
|
|
667
|
+
reconstructed.data[idx + 1] !== expected.data[idx + 1] ||
|
|
668
|
+
reconstructed.data[idx + 2] !== expected.data[idx + 2] ||
|
|
669
|
+
reconstructed.data[idx + 3] !== expected.data[idx + 3]
|
|
670
|
+
) {
|
|
671
|
+
pixelFault =
|
|
672
|
+
`frame ${JSON.stringify(mf.key)} reconstruction mismatch at canvas pixel (${x}, ${y}): ` +
|
|
673
|
+
`got ${rgbaString(reconstructed.data, idx)}, expected ${rgbaString(expected.data, idx)}`;
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
if (pixelFault !== null) {
|
|
678
|
+
faults.push(pixelFault);
|
|
679
|
+
break;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
return faults;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** Throw `SheetInvariantError` if `sheetPackFaults` finds any fault. */
|
|
688
|
+
export function verifySheetPack(
|
|
689
|
+
pages: ReadonlyArray<{ image: string; table: string; plate: Plate; json: SheetJsonHashDocument }>,
|
|
690
|
+
manifest: SheetManifest,
|
|
691
|
+
expectedUntrimmed: readonly Plate[],
|
|
692
|
+
setName: string,
|
|
693
|
+
): void {
|
|
694
|
+
const faults = sheetPackFaults(pages, manifest, expectedUntrimmed);
|
|
695
|
+
if (faults.length > 0) {
|
|
696
|
+
throw new SheetInvariantError(`sheet self-check failed on ${JSON.stringify(setName)}: ${faults[0]}`);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Render, trim, pack, format, and self-check one animation set's spritesheet
|
|
702
|
+
* in memory before any file is written to disk.
|
|
703
|
+
*/
|
|
704
|
+
export function buildSheetSet(opts: BuildSheetSetOptions): SheetSetPack {
|
|
705
|
+
const format = opts.format ?? DEFAULT_SHEET_FORMAT;
|
|
706
|
+
const pageEdges = opts.pageEdges ?? DEFAULT_PAGE_EDGES;
|
|
707
|
+
const source: SheetManifestSource = opts.source ?? {
|
|
708
|
+
skeleton: { file: 'skeleton.json', sha256: spineFileSha256(opts.rigName) },
|
|
709
|
+
model: null,
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
const namespace = sheetNamespaceOf({
|
|
713
|
+
rigName: opts.rigName,
|
|
714
|
+
setName: opts.setName,
|
|
715
|
+
skin: opts.skin,
|
|
716
|
+
subset: opts.subset,
|
|
717
|
+
environment: opts.environment,
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
const padding = SHEET_PADDING;
|
|
721
|
+
const maxEdge = SHEET_PAGE_SIZE;
|
|
722
|
+
const sourceSize = { w: opts.viewport.width, h: opts.viewport.height };
|
|
723
|
+
const pivot = sheetPivotOf(opts.viewport);
|
|
724
|
+
|
|
725
|
+
const untrimmedPlates: Plate[] = [];
|
|
726
|
+
const trimmedFrames: Array<{
|
|
727
|
+
index: number;
|
|
728
|
+
key: string;
|
|
729
|
+
time: number;
|
|
730
|
+
trimmed: TrimmedFrame;
|
|
731
|
+
}> = [];
|
|
732
|
+
|
|
733
|
+
for (let i = 0; i < opts.frames.length; i++) {
|
|
734
|
+
const frame = opts.frames[i];
|
|
735
|
+
const key = sheetFrameKey(namespace, i);
|
|
736
|
+
const untrimmed = renderFrame(frame, opts.atlasPages, opts.viewport, TRANSPARENT_BACKGROUND);
|
|
737
|
+
const trimmed = trimTransparentFrame(untrimmed);
|
|
738
|
+
const cellW = trimmed.width + 2 * padding;
|
|
739
|
+
const cellH = trimmed.height + 2 * padding;
|
|
740
|
+
if (cellW > maxEdge || cellH > maxEdge) {
|
|
741
|
+
throw new SheetUsageError(
|
|
742
|
+
`--sheet, ${JSON.stringify(opts.setName)} frame ${JSON.stringify(key)} (f${String(i).padStart(4, '0')}, t=${frame.time.toFixed(4)}s) ` +
|
|
743
|
+
`trims to ${trimmed.width}x${trimmed.height}px and with padding ${padding}px needs a ${cellW}x${cellH}px cell, ` +
|
|
744
|
+
`which does not fit a ${maxEdge}x${maxEdge}px page. Lower --max (currently ${opts.maxSide}) or render a smaller --viewport`,
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
untrimmedPlates.push(untrimmed);
|
|
748
|
+
trimmedFrames.push({ index: i, key, time: cleanZero(frame.time), trimmed });
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const packInputs: PackInput[] = trimmedFrames.map((item) => ({
|
|
752
|
+
region: item.key,
|
|
753
|
+
absPath: item.key,
|
|
754
|
+
width: item.trimmed.width,
|
|
755
|
+
height: item.trimmed.height,
|
|
756
|
+
plate: item.trimmed.plate,
|
|
757
|
+
}));
|
|
758
|
+
if (opts.reversePackInputs === true) packInputs.reverse();
|
|
759
|
+
|
|
760
|
+
const packed = packAtlas(packInputs, {
|
|
761
|
+
pageSize: maxEdge,
|
|
762
|
+
padding,
|
|
763
|
+
pageStem: SHEET_PAGE_STEM,
|
|
764
|
+
pageEdges,
|
|
765
|
+
shape: 'rect',
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
const placementByKey = new Map(packed.placements.map((pl) => [pl.region, pl]));
|
|
769
|
+
const pageTableNames = packed.pages.map((_, idx) => (idx === 0 ? `${SHEET_PAGE_STEM}.json` : `${SHEET_PAGE_STEM}${idx + 1}.json`));
|
|
770
|
+
|
|
771
|
+
const pages: SheetPackedPage[] = [];
|
|
772
|
+
const manifestPages: SheetManifestPage[] = [];
|
|
773
|
+
|
|
774
|
+
for (let p = 0; p < packed.pages.length; p++) {
|
|
775
|
+
const packedPage = packed.pages[p];
|
|
776
|
+
const imageName = packedPage.name;
|
|
777
|
+
const tableName = pageTableNames[p];
|
|
778
|
+
// Collect frames on this page in strict chronological index order (`0 .. sampled - 1`).
|
|
779
|
+
const onPageChronological = trimmedFrames.filter((item) => placementByKey.get(item.key)?.page === p);
|
|
780
|
+
const framesObj: Record<string, SheetJsonFrameEntry> = {};
|
|
781
|
+
const animList: string[] = [];
|
|
782
|
+
for (const item of onPageChronological) {
|
|
783
|
+
const place = placementByKey.get(item.key)!;
|
|
784
|
+
framesObj[item.key] = {
|
|
785
|
+
frame: { x: place.x, y: place.y, w: place.width, h: place.height },
|
|
786
|
+
rotated: false,
|
|
787
|
+
trimmed: item.trimmed.trimmed,
|
|
788
|
+
spriteSourceSize: {
|
|
789
|
+
x: item.trimmed.trimX,
|
|
790
|
+
y: item.trimmed.trimY,
|
|
791
|
+
w: item.trimmed.width,
|
|
792
|
+
h: item.trimmed.height,
|
|
793
|
+
},
|
|
794
|
+
sourceSize: { w: sourceSize.w, h: sourceSize.h },
|
|
795
|
+
anchor: { x: pivot.anchor.x, y: pivot.anchor.y },
|
|
796
|
+
};
|
|
797
|
+
animList.push(item.key);
|
|
798
|
+
}
|
|
799
|
+
const related = packed.pages.length > 1 ? pageTableNames.filter((_, idx) => idx !== p) : undefined;
|
|
800
|
+
const json: SheetJsonHashDocument = {
|
|
801
|
+
frames: framesObj,
|
|
802
|
+
animations: { [namespace]: animList },
|
|
803
|
+
meta: {
|
|
804
|
+
app: 'rig-c',
|
|
805
|
+
format: 'RGBA8888',
|
|
806
|
+
image: imageName,
|
|
807
|
+
size: { w: packedPage.width, h: packedPage.height },
|
|
808
|
+
scale: '1',
|
|
809
|
+
...(related === undefined ? {} : { related_multi_packs: related }),
|
|
810
|
+
},
|
|
811
|
+
};
|
|
812
|
+
const jsonText = `${JSON.stringify(json, null, 2)}\n`;
|
|
813
|
+
const occupancy = cleanZero(Number(packedPage.occupancy.toFixed(6)));
|
|
814
|
+
pages.push({
|
|
815
|
+
index: p,
|
|
816
|
+
image: imageName,
|
|
817
|
+
table: tableName,
|
|
818
|
+
plate: packedPage.plate,
|
|
819
|
+
json,
|
|
820
|
+
jsonText,
|
|
821
|
+
occupancy,
|
|
822
|
+
});
|
|
823
|
+
manifestPages.push({
|
|
824
|
+
index: p,
|
|
825
|
+
image: imageName,
|
|
826
|
+
table: tableName,
|
|
827
|
+
width: packedPage.width,
|
|
828
|
+
height: packedPage.height,
|
|
829
|
+
occupancy,
|
|
830
|
+
frames: animList,
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const manifestFrames: SheetManifestFrame[] = trimmedFrames.map((item) => {
|
|
835
|
+
const place = placementByKey.get(item.key)!;
|
|
836
|
+
const pageIdx = place.page;
|
|
837
|
+
return {
|
|
838
|
+
index: item.index,
|
|
839
|
+
key: item.key,
|
|
840
|
+
time: item.time,
|
|
841
|
+
page: pageIdx,
|
|
842
|
+
image: packed.pages[pageIdx].name,
|
|
843
|
+
table: pageTableNames[pageIdx],
|
|
844
|
+
empty: item.trimmed.empty,
|
|
845
|
+
frame: { x: place.x, y: place.y, w: place.width, h: place.height },
|
|
846
|
+
spriteSourceSize: {
|
|
847
|
+
x: item.trimmed.trimX,
|
|
848
|
+
y: item.trimmed.trimY,
|
|
849
|
+
w: item.trimmed.width,
|
|
850
|
+
h: item.trimmed.height,
|
|
851
|
+
},
|
|
852
|
+
sourceSize: { w: sourceSize.w, h: sourceSize.h },
|
|
853
|
+
anchor: { x: pivot.anchor.x, y: pivot.anchor.y },
|
|
854
|
+
};
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
const duration = opts.frames.length > 0 ? cleanZero(opts.frames[opts.frames.length - 1].time) : 0;
|
|
858
|
+
const manifest: SheetManifest = {
|
|
859
|
+
spec: SHEET_MANIFEST_SPEC,
|
|
860
|
+
format,
|
|
861
|
+
namespace,
|
|
862
|
+
source,
|
|
863
|
+
animation: opts.animation,
|
|
864
|
+
...(opts.skin === undefined ? {} : { skin: opts.skin }),
|
|
865
|
+
...(opts.subset === undefined
|
|
866
|
+
? {}
|
|
867
|
+
: opts.subset.mode === 'slots'
|
|
868
|
+
? { slots: [...opts.subset.names] }
|
|
869
|
+
: { hidden: [...opts.subset.names] }),
|
|
870
|
+
...(opts.environment === undefined ? {} : { environment: opts.environment }),
|
|
871
|
+
...(opts.pinned === undefined ? {} : { pinned: opts.pinned }),
|
|
872
|
+
fps: opts.fps,
|
|
873
|
+
duration,
|
|
874
|
+
sampled: opts.frames.length,
|
|
875
|
+
endpointPolicy: 'inclusive-grid',
|
|
876
|
+
alphaMode: 'straight',
|
|
877
|
+
filterPolicy: {
|
|
878
|
+
recommended: 'linear',
|
|
879
|
+
paddingPx: padding,
|
|
880
|
+
extrusion: 'clamp-edge',
|
|
881
|
+
},
|
|
882
|
+
pageEdges,
|
|
883
|
+
pageSize: maxEdge,
|
|
884
|
+
padding,
|
|
885
|
+
sourceSize,
|
|
886
|
+
viewport: sidecarViewport(opts.viewport),
|
|
887
|
+
pivot: {
|
|
888
|
+
world: [0, 0],
|
|
889
|
+
canvasPx: pivot.canvasPx,
|
|
890
|
+
anchor: pivot.anchor,
|
|
891
|
+
},
|
|
892
|
+
pages: manifestPages,
|
|
893
|
+
frames: manifestFrames,
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
verifySheetPack(pages, manifest, untrimmedPlates, opts.setName);
|
|
897
|
+
|
|
898
|
+
return {
|
|
899
|
+
setName: opts.setName,
|
|
900
|
+
namespace,
|
|
901
|
+
format,
|
|
902
|
+
pageEdges,
|
|
903
|
+
pages,
|
|
904
|
+
manifest,
|
|
905
|
+
manifestText: `${JSON.stringify(manifest, null, 2)}\n`,
|
|
906
|
+
untrimmedPlates,
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** `frames.json`'s per-set record of a spritesheet export (`FrameSet['sheet']`). */
|
|
911
|
+
export function sheetSidecarRecord(pack: SheetSetPack): NonNullable<FrameSet['sheet']> {
|
|
912
|
+
return {
|
|
913
|
+
manifest: SHEET_MANIFEST_FILE,
|
|
914
|
+
format: pack.format,
|
|
915
|
+
pageEdges: pack.pageEdges,
|
|
916
|
+
pages: pack.pages.length,
|
|
917
|
+
frames: pack.manifest.frames.length,
|
|
918
|
+
emptyFrames: pack.manifest.frames.filter((f) => f.empty).length,
|
|
919
|
+
};
|
|
920
|
+
}
|