spine-rigc 0.2.1
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/LICENSE +21 -0
- package/NOTICE.md +76 -0
- package/README.md +558 -0
- package/cli.ts +739 -0
- package/docs/AUTHORING.md +1303 -0
- package/docs/SPEC_COVERAGE.md +1109 -0
- package/package.json +65 -0
- package/src/check.ts +1714 -0
- package/src/compile.ts +1861 -0
- package/src/diff.ts +847 -0
- package/src/errors.ts +22 -0
- package/src/framing.ts +539 -0
- package/src/ladder.ts +121 -0
- package/src/mesh.ts +433 -0
- package/src/png.ts +50 -0
- package/src/render.ts +974 -0
- package/src/rig.ts +731 -0
- package/src/slots.ts +603 -0
- package/src/timelines.ts +253 -0
- package/src/transform.ts +130 -0
- package/src/types.ts +586 -0
- package/src/validate.ts +1586 -0
- package/tools/font5x7.ts +101 -0
- package/tools/plate.ts +286 -0
package/src/compile.ts
ADDED
|
@@ -0,0 +1,1861 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rigc compile — rig spec + motion spec (+ an optional cut manifest) -> Spine 4.3
|
|
3
|
+
* skeleton JSON and a one-part-per-page atlas. Pure data assembly: no spine-core
|
|
4
|
+
* here (that is the validator's job), no clock, no randomness.
|
|
5
|
+
*
|
|
6
|
+
* Three inputs, one domain each — [`src/rig.ts`](rig.ts) states the split in
|
|
7
|
+
* full. In one line: the **manifest** owns measured art, the **rig spec** owns
|
|
8
|
+
* skeleton structure, the **motion spec** owns time.
|
|
9
|
+
*
|
|
10
|
+
* ⭐ The rig spec is what this file used to hard-code. Until it existed the bone
|
|
11
|
+
* tree and the slot table were three tables in `src/archetype.ts`, a slot outside
|
|
12
|
+
* them was a compile error, and no skeleton anybody else owns could be stated at
|
|
13
|
+
* all (blocker B1). The two things that were genuinely code and stayed code are
|
|
14
|
+
* the **mesh generators** (`src/mesh.ts` — they encode a deformation model, not a
|
|
15
|
+
* table of numbers) and the **coordinate contract** (`src/transform.ts`).
|
|
16
|
+
*
|
|
17
|
+
* Determinism is a contract, not a habit: `validate` re-runs this and compares
|
|
18
|
+
* the two emits byte for byte (assertion A18).
|
|
19
|
+
*/
|
|
20
|
+
import { basename, dirname, relative, resolve } from 'node:path';
|
|
21
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
22
|
+
import { readPngInfo } from './png.ts';
|
|
23
|
+
import { CompileError, NotImplementedError } from './errors.ts';
|
|
24
|
+
import {
|
|
25
|
+
parseRigSpec,
|
|
26
|
+
type RigAttachment,
|
|
27
|
+
type RigBone,
|
|
28
|
+
type RigMeshAttachment,
|
|
29
|
+
type RigMeshBinding,
|
|
30
|
+
type RigRegionAttachment,
|
|
31
|
+
type RigSpec,
|
|
32
|
+
} from './rig.ts';
|
|
33
|
+
import { buildRibbonMesh, buildRingMesh, encodeWeightedVertices, MeshError, type MeshBoneRef } from './mesh.ts';
|
|
34
|
+
import { KEY_TIME_EPSILON } from './timelines.ts';
|
|
35
|
+
import {
|
|
36
|
+
computeWorldTransforms,
|
|
37
|
+
cropToSpineY,
|
|
38
|
+
normaliseDegrees,
|
|
39
|
+
screenToSpineDegrees,
|
|
40
|
+
toBoneLocal,
|
|
41
|
+
TransformError,
|
|
42
|
+
type BoneTransform,
|
|
43
|
+
} from './transform.ts';
|
|
44
|
+
import type {
|
|
45
|
+
CompileResult,
|
|
46
|
+
CompiledImage,
|
|
47
|
+
EasingHandles,
|
|
48
|
+
FaceManifest,
|
|
49
|
+
FaceManifestPart,
|
|
50
|
+
MotionDrawOrderKey,
|
|
51
|
+
MotionSpec,
|
|
52
|
+
MotionTrack,
|
|
53
|
+
RigInfo,
|
|
54
|
+
SpineAttachment,
|
|
55
|
+
SpineBone,
|
|
56
|
+
SpineConstraint,
|
|
57
|
+
SpineMeshAttachment,
|
|
58
|
+
SpineRegionAttachment,
|
|
59
|
+
SpineSkeletonJson,
|
|
60
|
+
SpineSlot,
|
|
61
|
+
SpineTimelineKey,
|
|
62
|
+
} from './types.ts';
|
|
63
|
+
|
|
64
|
+
export { CompileError, NotImplementedError };
|
|
65
|
+
|
|
66
|
+
/** The spine-core line the validator round-trips through. */
|
|
67
|
+
export const SPINE_VERSION = '4.3.13';
|
|
68
|
+
|
|
69
|
+
const FRAME = 1 / 60;
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// number formatting — deterministic, and free of "-0"
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
function r6(n: number): number {
|
|
76
|
+
const v = Math.round(n * 1e6) / 1e6;
|
|
77
|
+
return v === 0 ? 0 : v;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Rule 4, per timeline: no key may land past the animation's declared duration.
|
|
82
|
+
*
|
|
83
|
+
* Rule 4 itself compares one number per animation — the largest key time across
|
|
84
|
+
* every track — so a single track sitting on the declared duration answers for
|
|
85
|
+
* all of them, and a key past the end on some *other* track is invisible to it.
|
|
86
|
+
* That is exactly how rung 6 lost a one-frame attachment reveal; the tolerance
|
|
87
|
+
* story is in `KEY_TIME_EPSILON`.
|
|
88
|
+
*
|
|
89
|
+
* This is a refusal rather than an assertion because the key is the thing to
|
|
90
|
+
* change and the motion spec is the file it lives in: the message has to name
|
|
91
|
+
* the track and the key, and by gate time both are gone — the emitted skeleton
|
|
92
|
+
* carries no declared duration at all, which is why Rule 4 exists.
|
|
93
|
+
*/
|
|
94
|
+
function checkKeyTime(where: string, time: number, authored: number, duration: number): void {
|
|
95
|
+
const past = time - duration;
|
|
96
|
+
if (past <= KEY_TIME_EPSILON) return;
|
|
97
|
+
const at = time === authored ? `${time}s` : `t=${authored}, ${time}s after lag/stagger`;
|
|
98
|
+
throw new CompileError(
|
|
99
|
+
`${where}: key at ${at} is ${r6(past)}s past the declared duration ${duration}s — nothing that plays this ` +
|
|
100
|
+
`animation for the duration it declares ever reaches it. Move the key to ${duration}, or declare the ` +
|
|
101
|
+
`duration you meant.`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function channelHex(v: number): string {
|
|
106
|
+
const clamped = Math.max(0, Math.min(1, v));
|
|
107
|
+
return Math.round(clamped * 255)
|
|
108
|
+
.toString(16)
|
|
109
|
+
.padStart(2, '0');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function rgbaHex(v: number[]): string {
|
|
113
|
+
if (v.length !== 4) throw new CompileError(`rgba value needs 4 channels, got ${v.length}`);
|
|
114
|
+
return v.map(channelHex).join('');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Bone timeline shapes: which JSON fields a key carries, and their defaults.
|
|
119
|
+
*
|
|
120
|
+
* The defaults matter more than they look: Spine omits a field that equals the
|
|
121
|
+
* setup value, and `scale` defaults to 1 while `translate` defaults to 0. Emit
|
|
122
|
+
* `x: 0` on a scale key and the bone collapses to nothing, silently.
|
|
123
|
+
*/
|
|
124
|
+
const BONE_TRACKS: Record<string, { fields: string[]; identity: number[] }> = {
|
|
125
|
+
translate: { fields: ['x', 'y'], identity: [0, 0] },
|
|
126
|
+
translatex: { fields: ['value'], identity: [0] },
|
|
127
|
+
translatey: { fields: ['value'], identity: [0] },
|
|
128
|
+
scale: { fields: ['x', 'y'], identity: [1, 1] },
|
|
129
|
+
scalex: { fields: ['value'], identity: [1] },
|
|
130
|
+
scaley: { fields: ['value'], identity: [1] },
|
|
131
|
+
shear: { fields: ['x', 'y'], identity: [0, 0] },
|
|
132
|
+
shearx: { fields: ['value'], identity: [0] },
|
|
133
|
+
sheary: { fields: ['value'], identity: [0] },
|
|
134
|
+
rotate: { fields: ['value'], identity: [0] },
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Physics timelines. `mix` is the constraint's authority; `reset` is an event
|
|
139
|
+
* with no value — one key at the entry frame stops the constraint from flying
|
|
140
|
+
* in from whatever pose the previous animation left — solved in DATA rather
|
|
141
|
+
* than in caller glue.
|
|
142
|
+
*/
|
|
143
|
+
const PHYSICS_TRACKS: Record<string, { fields: string[]; identity: number[] }> = {
|
|
144
|
+
mix: { fields: ['value'], identity: [1] },
|
|
145
|
+
reset: { fields: [], identity: [] },
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** Physics constraint fields and their parser defaults (SkeletonJson.js:295-319). */
|
|
149
|
+
const PHYSICS_COMPONENTS = ['x', 'y', 'rotate', 'scaleX', 'shearX'] as const;
|
|
150
|
+
const PHYSICS_PARAMS: Array<[string, number]> = [
|
|
151
|
+
['inertia', 0.5],
|
|
152
|
+
['strength', 100],
|
|
153
|
+
['damping', 0.85],
|
|
154
|
+
['mass', 1],
|
|
155
|
+
['wind', 0],
|
|
156
|
+
['gravity', 0],
|
|
157
|
+
['mix', 1],
|
|
158
|
+
['fps', 60],
|
|
159
|
+
['limit', 5000],
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// curves
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Graph-view handles -> ABSOLUTE (time, value) control points.
|
|
168
|
+
*
|
|
169
|
+
* `Animation.setBezier` samples the cubic in the (time, value) plane, so the
|
|
170
|
+
* normalised handles an editor shows are NOT what the JSON holds. Writing the
|
|
171
|
+
* handles straight into the file loads without error and produces a different
|
|
172
|
+
* curve.
|
|
173
|
+
*
|
|
174
|
+
* Four numbers PER VALUE CHANNEL, concatenated in channel order. A short array
|
|
175
|
+
* multiplies `undefined` and yields a NaN curve, silently (case 6g).
|
|
176
|
+
*/
|
|
177
|
+
export function bezierForChannel(
|
|
178
|
+
handles: EasingHandles,
|
|
179
|
+
t1: number,
|
|
180
|
+
t2: number,
|
|
181
|
+
v1: number,
|
|
182
|
+
v2: number,
|
|
183
|
+
): [number, number, number, number] {
|
|
184
|
+
const [hx1, hy1, hx2, hy2] = handles;
|
|
185
|
+
return [
|
|
186
|
+
r6(t1 + (t2 - t1) * hx1),
|
|
187
|
+
r6(v1 + (v2 - v1) * hy1),
|
|
188
|
+
r6(t1 + (t2 - t1) * hx2),
|
|
189
|
+
r6(v1 + (v2 - v1) * hy2),
|
|
190
|
+
];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// inputs
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
function readJson<T>(path: string): T {
|
|
198
|
+
try {
|
|
199
|
+
return JSON.parse(readFileSync(path, 'utf8')) as T;
|
|
200
|
+
} catch (err) {
|
|
201
|
+
throw new CompileError(`cannot read ${path}: ${(err as Error).message}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function partWindow(part: FaceManifestPart, manifest: FaceManifest): {
|
|
206
|
+
x: number;
|
|
207
|
+
y: number;
|
|
208
|
+
w: number;
|
|
209
|
+
h: number;
|
|
210
|
+
} {
|
|
211
|
+
const [x, y] = part.offset;
|
|
212
|
+
const size = part.size ?? [manifest.crop.w, manifest.crop.h];
|
|
213
|
+
return { x, y, w: size[0], h: size[1] };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The base plate is the part whose window IS the crop, and that is a structural
|
|
218
|
+
* fact rather than a naming convention.
|
|
219
|
+
*
|
|
220
|
+
* It matters twice. A full-frame mesh is a full-frame canvas that can never
|
|
221
|
+
* dirty-skip, so the base must never be
|
|
222
|
+
* a mesh — and the validator recognises the same shape from the other side, which
|
|
223
|
+
* is why assertion A14 already covers this without a new check. And a full-frame
|
|
224
|
+
* region is the one page allowed to be opaque (A19).
|
|
225
|
+
*/
|
|
226
|
+
function isBasePlate(part: FaceManifestPart, manifest: FaceManifest): boolean {
|
|
227
|
+
const win = partWindow(part, manifest);
|
|
228
|
+
return win.x === 0 && win.y === 0 && win.w === manifest.crop.w && win.h === manifest.crop.h;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The rig slot a manifest part joins on. See the `parts` mapping below. */
|
|
232
|
+
function rigSlotOf(part: FaceManifestPart): string {
|
|
233
|
+
return part.rig_slot ?? part.slot;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The control bones a mesh part drives, in declaration order. */
|
|
237
|
+
function meshControlBones(part: FaceManifestPart): string[] {
|
|
238
|
+
const spec = part.mesh;
|
|
239
|
+
if (!spec) return [];
|
|
240
|
+
const kind = spec.kind ?? 'ring';
|
|
241
|
+
if (kind === 'ribbon') return spec.chain ?? [];
|
|
242
|
+
if (spec.control_bones?.length) return spec.control_bones;
|
|
243
|
+
return spec.control_bone ? [spec.control_bone] : [];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// compile
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
export interface CompileOptions {
|
|
251
|
+
/** The rig spec. Required: it is the skeleton's structure. */
|
|
252
|
+
rigPath: string;
|
|
253
|
+
motionPath: string;
|
|
254
|
+
/** Directory the atlas + skeleton will be written to (page names are relative to it). */
|
|
255
|
+
outDir: string;
|
|
256
|
+
/** The cut manifest. Absent for a skeleton with no measured art behind it. */
|
|
257
|
+
manifestPath?: string;
|
|
258
|
+
/** Overrides the rig spec's own `images` directory (CLI `--images <dir>`). */
|
|
259
|
+
imagesDir?: string;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function compile(opts: CompileOptions): CompileResult {
|
|
263
|
+
const rigPath = resolve(opts.rigPath);
|
|
264
|
+
const motionPath = resolve(opts.motionPath);
|
|
265
|
+
const outDir = resolve(opts.outDir);
|
|
266
|
+
const manifestPath = opts.manifestPath === undefined ? null : resolve(opts.manifestPath);
|
|
267
|
+
const manifestDir = manifestPath === null ? null : dirname(manifestPath);
|
|
268
|
+
|
|
269
|
+
const rig = parseRigSpec(readJson<unknown>(rigPath), rigPath);
|
|
270
|
+
const motion = readJson<MotionSpec>(motionPath);
|
|
271
|
+
const manifest = manifestPath === null ? null : readJson<FaceManifest>(manifestPath);
|
|
272
|
+
|
|
273
|
+
if (motion.spec !== 'rigc-motion/1') {
|
|
274
|
+
throw new CompileError(`unknown motion spec version: ${String(motion.spec)}`);
|
|
275
|
+
}
|
|
276
|
+
// The motion spec was authored against one formation. Pairing it with another
|
|
277
|
+
// rig would aim its keys at bones whose names happen to match and whose meaning
|
|
278
|
+
// does not — the class of wrongness that loads, plays and lies.
|
|
279
|
+
if (motion.archetype !== rig.name) {
|
|
280
|
+
throw new CompileError(
|
|
281
|
+
`motion spec names archetype "${motion.archetype}" but the rig spec at ${rigPath} is called "${rig.name}"`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// The stage. The rig may state it outright (a foreign skeleton has no crop);
|
|
286
|
+
// otherwise the manifest's crop is it. With neither there is nothing to
|
|
287
|
+
// measure a full-frame mesh against, so the compile stops rather than guess.
|
|
288
|
+
const stageWidth = rig.skeleton?.width ?? manifest?.crop.w;
|
|
289
|
+
const stageHeight = rig.skeleton?.height ?? manifest?.crop.h;
|
|
290
|
+
if (stageWidth === undefined || stageHeight === undefined) {
|
|
291
|
+
throw new CompileError(
|
|
292
|
+
'no stage size: give the rig spec a `skeleton.width`/`skeleton.height`, or compile against a cut manifest whose `crop` states them',
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
/** Crop height, for the y-down -> y-up flip. Only manifest data uses it. */
|
|
296
|
+
const cropH = manifest?.crop.h ?? stageHeight;
|
|
297
|
+
const imagesDir = opts.imagesDir !== undefined ? resolve(opts.imagesDir) : resolve(dirname(rigPath), rig.images ?? '.');
|
|
298
|
+
|
|
299
|
+
// -- 1. gather images ------------------------------------------------------
|
|
300
|
+
// Region name = attachment name = PNG basename.
|
|
301
|
+
const images: CompiledImage[] = [];
|
|
302
|
+
const droppedStates: CompileResult['droppedStates'] = [];
|
|
303
|
+
const seenRegions = new Set<string>();
|
|
304
|
+
|
|
305
|
+
const addImage = (relPath: string, baseDir: string, isBase: boolean): CompiledImage => {
|
|
306
|
+
const absPath = resolve(baseDir, relPath);
|
|
307
|
+
const region = basename(relPath, '.png');
|
|
308
|
+
if (seenRegions.has(region)) {
|
|
309
|
+
throw new CompileError(`duplicate region name "${region}" (${relPath})`);
|
|
310
|
+
}
|
|
311
|
+
if (!existsSync(absPath)) {
|
|
312
|
+
// Left to `readFileSync` this arrives as a raw ENOENT with a stack, which
|
|
313
|
+
// is the tool telling an agent about its own internals instead of about
|
|
314
|
+
// the rig. The validator's messages are the UI, and so are these.
|
|
315
|
+
throw new CompileError(`image "${relPath}" is not on disk at ${absPath}`);
|
|
316
|
+
}
|
|
317
|
+
const info = readPngInfo(absPath);
|
|
318
|
+
// Page name is the PNG path *relative to the atlas file*, so the viewer
|
|
319
|
+
// resolves it the way every Spine consumer does: against the atlas URL.
|
|
320
|
+
// The PNGs are not copied: they pass through untouched, and the atlas points
|
|
321
|
+
// at wherever they already live.
|
|
322
|
+
const page = relative(outDir, absPath).split('\\').join('/');
|
|
323
|
+
const img: CompiledImage = {
|
|
324
|
+
region,
|
|
325
|
+
page,
|
|
326
|
+
absPath,
|
|
327
|
+
width: info.width,
|
|
328
|
+
height: info.height,
|
|
329
|
+
hasAlpha: info.hasAlpha,
|
|
330
|
+
isBase,
|
|
331
|
+
};
|
|
332
|
+
seenRegions.add(region);
|
|
333
|
+
images.push(img);
|
|
334
|
+
return img;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
// A manifest may name a part the cut does not carry. A formation can declare
|
|
338
|
+
// more slots than any one cut fills, and a cut that shares a sprite with the
|
|
339
|
+
// scene around it has no plate of its own to point at — the manifest then
|
|
340
|
+
// records the part as `image: null` with no window at all. That entry is a
|
|
341
|
+
// documented ABSENCE, not a part — and it used to crash the compiler on its
|
|
342
|
+
// missing `offset` rather than being tolerated, so "the optional slots are
|
|
343
|
+
// optional" needed this line to actually be true.
|
|
344
|
+
const absentParts: CompileResult['absentParts'] = [];
|
|
345
|
+
const declaredParts = (manifest?.parts ?? []).filter((part) => {
|
|
346
|
+
if (part.image === null && !part.states) {
|
|
347
|
+
absentParts.push({ slot: rigSlotOf(part), why: 'manifest declares `image: null` and no states' });
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
return true;
|
|
351
|
+
});
|
|
352
|
+
// ⚠️ `rig_slot` is the join key, not `slot`. A cut manifest that doubles as the
|
|
353
|
+
// art pipeline's record carries slot names of its own and that pipeline's
|
|
354
|
+
// scripts select on them; the rig's slot table is what the runtime, the tooling
|
|
355
|
+
// and the viewer join on. So the mapping is manifest data, and the rig's table
|
|
356
|
+
// stays single-valued — one name per slot, which is the only way A26 and a
|
|
357
|
+
// "hide this slot" probe can mean the same thing on every cut.
|
|
358
|
+
const parts = declaredParts
|
|
359
|
+
.map((part) => (part.rig_slot && part.rig_slot !== part.slot ? { ...part, slot: part.rig_slot } : part))
|
|
360
|
+
.sort((a, b) => a.draw_order - b.draw_order);
|
|
361
|
+
|
|
362
|
+
const rigSlotIndex = new Map(rig.slots.map((slot, i) => [slot.name, i]));
|
|
363
|
+
for (const part of parts) {
|
|
364
|
+
if (!rigSlotIndex.has(part.slot)) {
|
|
365
|
+
throw new CompileError(
|
|
366
|
+
`the manifest binds a part to slot "${part.slot}" (via rig_slot), which the rig "${rig.name}" does not declare — add the slot to the rig rather than inventing one here`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// 🔑 Two files now state a draw order — the manifest's `draw_order` numbers and
|
|
371
|
+
// the rig's slot array — and two sources for one fact is how they come to
|
|
372
|
+
// disagree. The rig's array wins (it IS the emitted order, which is Spine's own
|
|
373
|
+
// semantics), and a manifest that orders its parts differently is refused here
|
|
374
|
+
// rather than silently overruled.
|
|
375
|
+
let orderCursor = -1;
|
|
376
|
+
for (const part of parts) {
|
|
377
|
+
const at = rigSlotIndex.get(part.slot)!;
|
|
378
|
+
if (at < orderCursor) {
|
|
379
|
+
throw new CompileError(
|
|
380
|
+
`the manifest draws "${part.slot}" (draw_order ${part.draw_order}) out of the rig's slot order; the rig's slots array IS the draw order`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
orderCursor = at;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** slot -> [attachment names], in the order the manifest lists the states. */
|
|
387
|
+
const slotAttachments = new Map<string, string[]>();
|
|
388
|
+
|
|
389
|
+
// Mesh parts, checked against the rig's budget before any geometry runs.
|
|
390
|
+
const meshBudget = rig.invariants?.meshSlots ?? 0;
|
|
391
|
+
const meshParts = parts.filter((part) => part.mesh);
|
|
392
|
+
if (meshParts.length > meshBudget) {
|
|
393
|
+
throw new CompileError(
|
|
394
|
+
`${meshParts.length} mesh slot(s) declared but the rig "${rig.name}" allows ${meshBudget}` +
|
|
395
|
+
' — raise `invariants.meshSlots` in the rig spec if that budget is the thing being changed',
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
for (const part of meshParts) {
|
|
399
|
+
if (isBasePlate(part, manifest!)) {
|
|
400
|
+
// A base plate mesh is a full-frame canvas every frame.
|
|
401
|
+
throw new CompileError(`slot "${part.slot}" is the base plate; it must never be a mesh`);
|
|
402
|
+
}
|
|
403
|
+
const spec = part.mesh!;
|
|
404
|
+
const kind = spec.kind ?? 'ring';
|
|
405
|
+
if (kind === 'ring') {
|
|
406
|
+
if (!part.polygon?.length) {
|
|
407
|
+
throw new CompileError(`slot "${part.slot}" declares a ring mesh but has no polygon to use as its rim`);
|
|
408
|
+
}
|
|
409
|
+
if (spec.hull !== 'polygon') {
|
|
410
|
+
throw new CompileError(`slot "${part.slot}": mesh.hull must be "polygon", got ${JSON.stringify(spec.hull)}`);
|
|
411
|
+
}
|
|
412
|
+
if (!spec.center || spec.inner === undefined) {
|
|
413
|
+
throw new CompileError(`slot "${part.slot}": a ring mesh needs mesh.center and mesh.inner`);
|
|
414
|
+
}
|
|
415
|
+
} else {
|
|
416
|
+
if (!spec.rows || !spec.chain?.length) {
|
|
417
|
+
throw new CompileError(`slot "${part.slot}": a ribbon mesh needs mesh.rows and a mesh.chain`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (!meshControlBones(part).length) {
|
|
421
|
+
throw new CompileError(`slot "${part.slot}": a mesh with no control bone deforms nothing`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
for (const part of parts) {
|
|
426
|
+
const win = partWindow(part, manifest!);
|
|
427
|
+
if (part.image) {
|
|
428
|
+
// One unconditional attachment: the base plate, and every joint part.
|
|
429
|
+
const img = addImage(part.image, manifestDir!, isBasePlate(part, manifest!));
|
|
430
|
+
if (img.width !== win.w || img.height !== win.h) {
|
|
431
|
+
throw new CompileError(
|
|
432
|
+
`${part.image} is ${img.width}x${img.height} but the manifest window for "${part.slot}" is ${win.w}x${win.h}`,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
slotAttachments.set(part.slot, [img.region]);
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
const names: string[] = [];
|
|
439
|
+
for (const [state, relPath] of Object.entries(part.states ?? {})) {
|
|
440
|
+
if (relPath === null) continue; // base pixels show through; nothing to emit
|
|
441
|
+
const absPath = resolve(manifestDir!, relPath);
|
|
442
|
+
if (!existsSync(absPath)) {
|
|
443
|
+
// A manifest can outlive a state whose art was dropped. It still lists
|
|
444
|
+
// it, so the compiler reports the gap rather than pretending either way.
|
|
445
|
+
droppedStates.push({ slot: part.slot, state, path: relPath });
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
const img = addImage(relPath, manifestDir!, false);
|
|
449
|
+
if (img.width !== win.w || img.height !== win.h) {
|
|
450
|
+
throw new CompileError(
|
|
451
|
+
`${relPath} is ${img.width}x${img.height} but slot "${part.slot}" declares ${win.w}x${win.h}`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
names.push(img.region);
|
|
455
|
+
}
|
|
456
|
+
slotAttachments.set(part.slot, names);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Attachments the RIG declares. A cut with a manifest leaves `skins` empty and
|
|
460
|
+
// gets its attachments from the parts above; a foreign skeleton has no manifest
|
|
461
|
+
// and states them here. A slot filled from both is a compile error, because the
|
|
462
|
+
// two would then be two records of one thing.
|
|
463
|
+
const skinNames = Object.keys(rig.skins ?? {});
|
|
464
|
+
const rigAttachmentNames = new Map<string, string[]>();
|
|
465
|
+
for (const skinName of skinNames) {
|
|
466
|
+
for (const [slotName, placeholders] of Object.entries(rig.skins![skinName])) {
|
|
467
|
+
if (!rigSlotIndex.has(slotName)) {
|
|
468
|
+
throw new CompileError(`rig skin "${skinName}" gives attachments to slot "${slotName}", which the rig does not declare`);
|
|
469
|
+
}
|
|
470
|
+
if (slotAttachments.has(slotName)) {
|
|
471
|
+
throw new CompileError(
|
|
472
|
+
`slot "${slotName}" is filled by a manifest part AND by rig skin "${skinName}"; one slot, one source of attachments`,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
const names = rigAttachmentNames.get(slotName) ?? [];
|
|
476
|
+
for (const [placeholder, att] of Object.entries(placeholders)) {
|
|
477
|
+
if (names.includes(placeholder)) continue;
|
|
478
|
+
names.push(placeholder);
|
|
479
|
+
const image = (att as RigRegionAttachment).image;
|
|
480
|
+
if (typeof image === 'string' && !seenRegions.has(basename(image, '.png'))) {
|
|
481
|
+
addImage(image, imagesDir, false);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
rigAttachmentNames.set(slotName, names);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// -- 2. atlas --------------------------------------------------------------
|
|
489
|
+
// One part = one page. No packer, so no PMA trap, no rotation, no strip
|
|
490
|
+
// offsets. Region covers the page exactly => u2=v2=1.
|
|
491
|
+
//
|
|
492
|
+
// Two text-shape traps are load-bearing here:
|
|
493
|
+
// * a region name is the RAW line, not a trimmed one -> no indentation;
|
|
494
|
+
// * a blank line closes the page block -> none between header and regions.
|
|
495
|
+
const atlasLines: string[] = [];
|
|
496
|
+
images.forEach((img, i) => {
|
|
497
|
+
if (i > 0) atlasLines.push(''); // exactly one blank line BETWEEN pages
|
|
498
|
+
atlasLines.push(img.page);
|
|
499
|
+
atlasLines.push(`size: ${img.width}, ${img.height}`);
|
|
500
|
+
atlasLines.push('filter: Linear, Linear');
|
|
501
|
+
atlasLines.push('pma: false');
|
|
502
|
+
atlasLines.push(img.region);
|
|
503
|
+
atlasLines.push(`bounds: 0, 0, ${img.width}, ${img.height}`);
|
|
504
|
+
atlasLines.push(`offsets: 0, 0, ${img.width}, ${img.height}`);
|
|
505
|
+
atlasLines.push('rotate: 0');
|
|
506
|
+
});
|
|
507
|
+
const atlasText = `${atlasLines.join('\n')}\n`;
|
|
508
|
+
|
|
509
|
+
// -- 3. bones --------------------------------------------------------------
|
|
510
|
+
//
|
|
511
|
+
// One path for every rig, because the two the archetype tables used to have
|
|
512
|
+
// (an explicit tree placed by manifest anchors, and one bone per slot at the
|
|
513
|
+
// part window's centre) are the same operation over a different crop point.
|
|
514
|
+
// What the rig spec chooses is WHERE the point comes from; the flip into Spine
|
|
515
|
+
// world and the inverse into the parent's local space are the same either way.
|
|
516
|
+
if (manifest) checkAxisSelfConsistency(manifest);
|
|
517
|
+
const axisSpineDeg = manifest?.axis ? screenToSpineDegrees(manifest.axis.deg) : null;
|
|
518
|
+
const partBySlot = new Map(parts.map((part) => [part.slot, part]));
|
|
519
|
+
|
|
520
|
+
const bones: SpineBone[] = [];
|
|
521
|
+
for (const spec of rig.bones) {
|
|
522
|
+
bones.push(buildBone(spec, bones, { rig, manifest, cropH, axisSpineDeg, partBySlot }));
|
|
523
|
+
}
|
|
524
|
+
const boneNames = new Set(bones.map((b) => b.name));
|
|
525
|
+
let transforms: Map<string, BoneTransform>;
|
|
526
|
+
try {
|
|
527
|
+
transforms = computeWorldTransforms(bones);
|
|
528
|
+
} catch (err) {
|
|
529
|
+
if (err instanceof TransformError) throw new CompileError(err.message);
|
|
530
|
+
throw err;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
for (const part of parts) {
|
|
534
|
+
for (const name of meshControlBones(part)) {
|
|
535
|
+
if (!boneNames.has(name)) {
|
|
536
|
+
throw new CompileError(
|
|
537
|
+
`slot "${part.slot}" drives control bone "${name}", which the rig "${rig.name}" does not declare`,
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// -- 4. slots + skins ------------------------------------------------------
|
|
544
|
+
// Draw order IS the slots array order. No separate field,
|
|
545
|
+
// and the rig's array is that order.
|
|
546
|
+
const slots: SpineSlot[] = [];
|
|
547
|
+
const skinTables = new Map<string, Record<string, Record<string, SpineAttachment>>>();
|
|
548
|
+
const tableFor = (skinName: string): Record<string, Record<string, SpineAttachment>> => {
|
|
549
|
+
let table = skinTables.get(skinName);
|
|
550
|
+
if (!table) {
|
|
551
|
+
table = {};
|
|
552
|
+
skinTables.set(skinName, table);
|
|
553
|
+
}
|
|
554
|
+
return table;
|
|
555
|
+
};
|
|
556
|
+
tableFor('default'); // rigc always emits a default skin, even when it is empty
|
|
557
|
+
const meshBones = new Set<string>();
|
|
558
|
+
const meshes: CompileResult['meshes'] = [];
|
|
559
|
+
|
|
560
|
+
for (const rigSlot of rig.slots) {
|
|
561
|
+
const part = partBySlot.get(rigSlot.name);
|
|
562
|
+
const names = slotAttachments.get(rigSlot.name) ?? rigAttachmentNames.get(rigSlot.name) ?? [];
|
|
563
|
+
if (!names.length) continue;
|
|
564
|
+
|
|
565
|
+
const setup = motion.setup?.[rigSlot.name];
|
|
566
|
+
if (setup !== undefined && rigSlot.attachment !== undefined) {
|
|
567
|
+
throw new CompileError(
|
|
568
|
+
`slot "${rigSlot.name}" has a setup attachment in the rig spec AND in the motion spec; the setup pose has one author`,
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
let setupAttachment: string | null;
|
|
572
|
+
if (setup !== undefined) setupAttachment = setup.attachment ?? null;
|
|
573
|
+
else if (rigSlot.attachment !== undefined) setupAttachment = rigSlot.attachment;
|
|
574
|
+
else {
|
|
575
|
+
throw new CompileError(
|
|
576
|
+
`no setup pose for slot "${rigSlot.name}": give the motion spec a \`setup\` entry or the rig slot an \`attachment\` — the compiler will not guess one`,
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
if (setupAttachment !== null && !names.includes(setupAttachment)) {
|
|
580
|
+
throw new CompileError(
|
|
581
|
+
`setup attachment "${setupAttachment}" for slot "${rigSlot.name}" is not one of [${names.join(', ')}]`,
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
if (setup?.color && rigSlot.color !== undefined) {
|
|
585
|
+
throw new CompileError(`slot "${rigSlot.name}" has a setup colour in the rig spec AND in the motion spec`);
|
|
586
|
+
}
|
|
587
|
+
const slot: SpineSlot = { name: rigSlot.name, bone: rigSlot.bone };
|
|
588
|
+
if (setupAttachment !== null) slot.attachment = setupAttachment;
|
|
589
|
+
if (setup?.color) slot.color = rgbaHex(setup.color);
|
|
590
|
+
else if (rigSlot.color !== undefined) slot.color = rigSlot.color;
|
|
591
|
+
if (rigSlot.dark !== undefined) slot.dark = rigSlot.dark;
|
|
592
|
+
if (rigSlot.blend !== undefined) slot.blend = rigSlot.blend;
|
|
593
|
+
slots.push(slot);
|
|
594
|
+
|
|
595
|
+
if (part) {
|
|
596
|
+
const perSlot: Record<string, SpineAttachment> = {};
|
|
597
|
+
const mesh = part.mesh ? buildMesh(part, manifest!, bones, transforms, rigSlot.bone) : null;
|
|
598
|
+
for (const name of names) {
|
|
599
|
+
const img = images.find((im) => im.region === name);
|
|
600
|
+
if (!img) throw new CompileError(`internal: no image for attachment ${name}`);
|
|
601
|
+
if (mesh) {
|
|
602
|
+
// Every state of a mesh slot gets the SAME geometry. That is what makes
|
|
603
|
+
// an attachment swap mid-deform safe: the control bone's pose means the
|
|
604
|
+
// same thing under all of them, so the swap and the deform do not fight.
|
|
605
|
+
perSlot[name] = { ...mesh.attachment };
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
perSlot[name] = placeRegion(part, manifest!, transforms.get(rigSlot.bone)!, img);
|
|
609
|
+
}
|
|
610
|
+
if (mesh) {
|
|
611
|
+
const controls = meshControlBones(part);
|
|
612
|
+
meshBones.add(rigSlot.bone);
|
|
613
|
+
for (const name of controls) meshBones.add(name);
|
|
614
|
+
meshes.push({
|
|
615
|
+
slot: rigSlot.name,
|
|
616
|
+
kind: mesh.kind,
|
|
617
|
+
attachments: names,
|
|
618
|
+
vertices: mesh.attachment.uvs.length / 2,
|
|
619
|
+
triangles: mesh.attachment.triangles.length / 3,
|
|
620
|
+
bones: [rigSlot.bone, ...controls],
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
tableFor('default')[rigSlot.name] = perSlot;
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
for (const skinName of skinNames) {
|
|
628
|
+
const placeholders = rig.skins![skinName][rigSlot.name];
|
|
629
|
+
if (!placeholders) continue;
|
|
630
|
+
const perSlot: Record<string, SpineAttachment> = {};
|
|
631
|
+
for (const [placeholder, att] of Object.entries(placeholders)) {
|
|
632
|
+
const where = `skin "${skinName}" slot "${rigSlot.name}" attachment "${placeholder}"`;
|
|
633
|
+
perSlot[placeholder] = buildRigAttachment(att, placeholder, where, {
|
|
634
|
+
images,
|
|
635
|
+
bones,
|
|
636
|
+
transforms,
|
|
637
|
+
meshBones,
|
|
638
|
+
meshes,
|
|
639
|
+
slotName: rigSlot.name,
|
|
640
|
+
anchorBone: rigSlot.bone,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
tableFor(skinName)[rigSlot.name] = perSlot;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
// 📐 The implicit budget of 0 is a statement about rigc's own GENERATORS: a rig
|
|
647
|
+
// that declares no `invariants.meshSlots` has not asked rigc to build any mesh,
|
|
648
|
+
// so building one is a mistake. It is not a statement about geometry somebody
|
|
649
|
+
// else drew — `RigInvariants.meshTriangles` says the same thing in words:
|
|
650
|
+
// a number baked in here would be one project's frame time masquerading as a
|
|
651
|
+
// property of the format. So authored meshes count against a budget the rig
|
|
652
|
+
// states out loud, and against nothing when it states none.
|
|
653
|
+
const budgeted = rig.invariants?.meshSlots === undefined ? meshes.filter((m) => m.kind !== 'authored') : meshes;
|
|
654
|
+
if (budgeted.length > meshBudget) {
|
|
655
|
+
throw new CompileError(`${budgeted.length} mesh slot(s) emitted but the rig "${rig.name}" allows ${meshBudget}`);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// -- 4b. constraints -------------------------------------------------------
|
|
659
|
+
// One top-level `constraints` array, `type` per entry. Rig-declared first
|
|
660
|
+
// (structure), then the motion spec's physics table (tuning). A name in both is
|
|
661
|
+
// refused: `mix` timelines resolve by name, and two constraints answering to
|
|
662
|
+
// one name is a timeline driving something nobody chose.
|
|
663
|
+
const constraints: SpineConstraint[] = [];
|
|
664
|
+
const physicsReport: CompileResult['physics'] = [];
|
|
665
|
+
const constraintNames = new Set<string>();
|
|
666
|
+
for (const spec of rig.constraints ?? []) {
|
|
667
|
+
constraints.push(buildRigConstraint(spec as RigConstraintInput, boneNames));
|
|
668
|
+
constraintNames.add(spec.name);
|
|
669
|
+
}
|
|
670
|
+
for (const [name, spec] of Object.entries(motion.physics ?? {})) {
|
|
671
|
+
if (constraintNames.has(name)) {
|
|
672
|
+
throw new CompileError(`constraint "${name}" is declared in both the rig spec and the motion spec's physics table`);
|
|
673
|
+
}
|
|
674
|
+
constraintNames.add(name);
|
|
675
|
+
if (!boneNames.has(spec.bone)) {
|
|
676
|
+
throw new CompileError(`physics constraint "${name}" targets unknown bone "${spec.bone}"`);
|
|
677
|
+
}
|
|
678
|
+
const entry: SpineConstraint = {
|
|
679
|
+
name,
|
|
680
|
+
type: 'physics',
|
|
681
|
+
bone: spec.bone,
|
|
682
|
+
};
|
|
683
|
+
const components: string[] = [];
|
|
684
|
+
for (const comp of PHYSICS_COMPONENTS) {
|
|
685
|
+
const v = spec[comp];
|
|
686
|
+
if (v === undefined || v === 0) continue;
|
|
687
|
+
entry[comp] = r6(v);
|
|
688
|
+
components.push(comp);
|
|
689
|
+
}
|
|
690
|
+
if (!components.length) {
|
|
691
|
+
// The parser is happy with this and the constraint does nothing at all.
|
|
692
|
+
// A23 catches it too; refusing here means it never reaches the gate.
|
|
693
|
+
throw new CompileError(
|
|
694
|
+
`physics constraint "${name}" drives no component — set at least one of ${PHYSICS_COMPONENTS.join('/')}`,
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
for (const [param, dflt] of PHYSICS_PARAMS) {
|
|
698
|
+
const v = spec[param as keyof typeof spec] as number | undefined;
|
|
699
|
+
if (v === undefined || v === dflt) continue;
|
|
700
|
+
entry[param] = r6(v);
|
|
701
|
+
}
|
|
702
|
+
constraints.push(entry);
|
|
703
|
+
physicsReport.push({
|
|
704
|
+
name,
|
|
705
|
+
bone: spec.bone,
|
|
706
|
+
components,
|
|
707
|
+
mix: spec.mix ?? 1,
|
|
708
|
+
drivesMesh: meshBones.has(spec.bone),
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// -- 5. animations ---------------------------------------------------------
|
|
713
|
+
const animations: SpineSkeletonJson['animations'] = {};
|
|
714
|
+
const declaredDurations: Record<string, number> = {};
|
|
715
|
+
const slotNames = new Set(slots.map((s) => s.name));
|
|
716
|
+
|
|
717
|
+
for (const [animName, anim] of Object.entries(motion.animations)) {
|
|
718
|
+
declaredDurations[animName] = anim.duration;
|
|
719
|
+
const slotTimelines: Record<string, Record<string, SpineTimelineKey[]>> = {};
|
|
720
|
+
const boneTimelines: Record<string, Record<string, SpineTimelineKey[]>> = {};
|
|
721
|
+
const physicsTimelines: Record<string, Record<string, SpineTimelineKey[]>> = {};
|
|
722
|
+
const claimed = new Set<string>();
|
|
723
|
+
let compiledDuration = 0;
|
|
724
|
+
|
|
725
|
+
for (const track of anim.tracks) {
|
|
726
|
+
const isPhysicsTrack = track.property in PHYSICS_TRACKS;
|
|
727
|
+
const isBoneTrack = !isPhysicsTrack && track.property in BONE_TRACKS;
|
|
728
|
+
const targets = resolveTargets(track, motion, animName);
|
|
729
|
+
targets.forEach((target, index) => {
|
|
730
|
+
if (isPhysicsTrack) {
|
|
731
|
+
if (!constraintNames.has(target)) {
|
|
732
|
+
throw new CompileError(`animation "${animName}" keys unknown physics constraint "${target}"`);
|
|
733
|
+
}
|
|
734
|
+
} else if (isBoneTrack) {
|
|
735
|
+
if (!boneNames.has(target)) {
|
|
736
|
+
throw new CompileError(`animation "${animName}" keys unknown bone "${target}"`);
|
|
737
|
+
}
|
|
738
|
+
} else if (!slotNames.has(target)) {
|
|
739
|
+
throw new CompileError(`animation "${animName}" targets unknown slot "${target}"`);
|
|
740
|
+
}
|
|
741
|
+
const claim = `${target}.${track.property}`;
|
|
742
|
+
if (claimed.has(claim)) {
|
|
743
|
+
throw new CompileError(
|
|
744
|
+
`animation "${animName}" has two tracks on ${claim}; merge them into one track`,
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
claimed.add(claim);
|
|
748
|
+
|
|
749
|
+
const shift = (track.lag ?? 0) + (track.stagger ?? 0) * index;
|
|
750
|
+
const keys = isPhysicsTrack
|
|
751
|
+
? compileValueTrack(track, motion, animName, anim.duration, target, shift, PHYSICS_TRACKS, 'physics constraint')
|
|
752
|
+
: isBoneTrack
|
|
753
|
+
? compileValueTrack(track, motion, animName, anim.duration, target, shift, BONE_TRACKS, 'bone')
|
|
754
|
+
: compileTrack(track, motion, animName, anim.duration, target, shift, tableFor('default'));
|
|
755
|
+
for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
|
|
756
|
+
if (isPhysicsTrack) (physicsTimelines[target] ??= {})[track.property] = keys;
|
|
757
|
+
else if (isBoneTrack) (boneTimelines[target] ??= {})[track.property] = keys;
|
|
758
|
+
else (slotTimelines[target] ??= {})[track.property] = keys;
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const drawOrder = anim.drawOrder ? compileDrawOrder(anim.drawOrder, animName, anim.duration, slots) : null;
|
|
763
|
+
if (drawOrder) for (const key of drawOrder) compiledDuration = Math.max(compiledDuration, key.time as number);
|
|
764
|
+
|
|
765
|
+
// Rule 4: the declared duration is verified, because skeleton JSON does not
|
|
766
|
+
// carry one — the loader takes the max key time.
|
|
767
|
+
//
|
|
768
|
+
// This arm is about the DECLARED DURATION being wrong, so it compares one
|
|
769
|
+
// number per animation and tolerates a frame of it. The other arm —
|
|
770
|
+
// `checkKeyTime`, above, per key — is about a key landing past the end, and
|
|
771
|
+
// a frame is 16,667 times too coarse to see one. Both are needed: this one
|
|
772
|
+
// catches an animation that stops a second early, and only that one catches
|
|
773
|
+
// a key on a track whose neighbour already sits on the declared duration.
|
|
774
|
+
if (Math.abs(compiledDuration - anim.duration) > FRAME) {
|
|
775
|
+
throw new CompileError(
|
|
776
|
+
`animation "${animName}" declares duration ${anim.duration}s but its last key is at ${compiledDuration}s`,
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
animations[animName] = {};
|
|
780
|
+
if (Object.keys(slotTimelines).length) animations[animName].slots = slotTimelines;
|
|
781
|
+
if (Object.keys(boneTimelines).length) animations[animName].bones = boneTimelines;
|
|
782
|
+
if (Object.keys(physicsTimelines).length) animations[animName].physics = physicsTimelines;
|
|
783
|
+
if (drawOrder) animations[animName].drawOrder = drawOrder;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// -- 6. assemble -----------------------------------------------------------
|
|
787
|
+
const header: SpineSkeletonJson['skeleton'] = {
|
|
788
|
+
spine: SPINE_VERSION,
|
|
789
|
+
x: rig.skeleton?.x ?? 0,
|
|
790
|
+
y: rig.skeleton?.y ?? 0,
|
|
791
|
+
width: stageWidth,
|
|
792
|
+
height: stageHeight,
|
|
793
|
+
};
|
|
794
|
+
if (rig.skeleton?.fps !== undefined) header.fps = rig.skeleton.fps;
|
|
795
|
+
if (rig.skeleton?.referenceScale !== undefined) header.referenceScale = rig.skeleton.referenceScale;
|
|
796
|
+
if (rig.skeleton?.images !== undefined) header.images = rig.skeleton.images;
|
|
797
|
+
|
|
798
|
+
const skeleton: SpineSkeletonJson = {
|
|
799
|
+
skeleton: header,
|
|
800
|
+
bones,
|
|
801
|
+
slots,
|
|
802
|
+
skins: [...skinTables.entries()].map(([name, attachments]) => ({ name, attachments })),
|
|
803
|
+
animations,
|
|
804
|
+
};
|
|
805
|
+
if (constraints.length) skeleton.constraints = constraints;
|
|
806
|
+
|
|
807
|
+
for (const slot of slots) {
|
|
808
|
+
if (!boneNames.has(slot.bone)) throw new CompileError(`slot "${slot.name}" has no bone`);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
return {
|
|
812
|
+
skeleton,
|
|
813
|
+
skeletonText: `${JSON.stringify(skeleton, null, 2)}\n`,
|
|
814
|
+
atlasText,
|
|
815
|
+
images,
|
|
816
|
+
droppedStates,
|
|
817
|
+
absentParts,
|
|
818
|
+
declaredDurations,
|
|
819
|
+
meshBones: [...meshBones],
|
|
820
|
+
meshes,
|
|
821
|
+
physics: physicsReport,
|
|
822
|
+
rig: buildRigInfo(rig, bones, meshes, manifest),
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// ---------------------------------------------------------------------------
|
|
827
|
+
// bones
|
|
828
|
+
// ---------------------------------------------------------------------------
|
|
829
|
+
|
|
830
|
+
interface BoneContext {
|
|
831
|
+
rig: RigSpec;
|
|
832
|
+
manifest: FaceManifest | null;
|
|
833
|
+
cropH: number;
|
|
834
|
+
axisSpineDeg: number | null;
|
|
835
|
+
partBySlot: Map<string, FaceManifestPart>;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* One rig bone -> one emitted bone.
|
|
840
|
+
*
|
|
841
|
+
* 🔑 A field is emitted exactly when the spec declared it. That is not Spine's
|
|
842
|
+
* own exporter convention (it omits anything equal to the default) and the
|
|
843
|
+
* difference is deliberate: a formation may need to say `x: 0` out loud, and
|
|
844
|
+
* deciding emission from the arithmetic rather than from the author's text makes
|
|
845
|
+
* the file depend on a rounding.
|
|
846
|
+
*/
|
|
847
|
+
function buildBone(spec: RigBone, soFar: SpineBone[], ctx: BoneContext): SpineBone {
|
|
848
|
+
const bone: SpineBone = { name: spec.name };
|
|
849
|
+
if (spec.parent !== undefined) bone.parent = spec.parent;
|
|
850
|
+
if (spec.length !== undefined) bone.length = r6(spec.length);
|
|
851
|
+
|
|
852
|
+
const crop = cropPointOf(spec, ctx);
|
|
853
|
+
if (crop) {
|
|
854
|
+
// Crop pixels (y down) -> Spine world (y up) -> the parent's local space. The
|
|
855
|
+
// inverse is the same one the mesh binder uses, so a rotated parent (the axis
|
|
856
|
+
// bone, a grip) is handled once rather than per call site.
|
|
857
|
+
const world: [number, number] = [crop[0], cropToSpineY(crop[1], ctx.cropH)];
|
|
858
|
+
if (spec.parent === undefined) {
|
|
859
|
+
bone.x = r6(world[0]);
|
|
860
|
+
bone.y = r6(world[1]);
|
|
861
|
+
} else {
|
|
862
|
+
const parent = computeWorldTransforms(soFar).get(spec.parent);
|
|
863
|
+
if (!parent) throw new CompileError(`bone "${spec.name}" names parent "${spec.parent}", which is declared after it`);
|
|
864
|
+
const [lx, ly] = toBoneLocal(parent, world[0], world[1]);
|
|
865
|
+
bone.x = lx;
|
|
866
|
+
bone.y = ly;
|
|
867
|
+
}
|
|
868
|
+
} else {
|
|
869
|
+
if (spec.x !== undefined) bone.x = r6(spec.x);
|
|
870
|
+
if (spec.y !== undefined) bone.y = r6(spec.y);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const rotation = rotationOf(spec, ctx);
|
|
874
|
+
if (rotation !== null) bone.rotation = r6(rotation);
|
|
875
|
+
if (spec.scaleX !== undefined) bone.scaleX = r6(spec.scaleX);
|
|
876
|
+
if (spec.scaleY !== undefined) bone.scaleY = r6(spec.scaleY);
|
|
877
|
+
if (spec.shearX !== undefined) bone.shearX = r6(spec.shearX);
|
|
878
|
+
if (spec.shearY !== undefined) bone.shearY = r6(spec.shearY);
|
|
879
|
+
if (spec.inherit !== undefined) bone.inherit = spec.inherit;
|
|
880
|
+
if (spec.skin !== undefined) bone.skin = spec.skin;
|
|
881
|
+
if (spec.color !== undefined) bone.color = spec.color;
|
|
882
|
+
if (spec.icon !== undefined) bone.icon = spec.icon;
|
|
883
|
+
return bone;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** The crop-pixel point a bone's `from` names, or null when it declares none. */
|
|
887
|
+
function cropPointOf(spec: RigBone, ctx: BoneContext): [number, number] | null {
|
|
888
|
+
const from = spec.from;
|
|
889
|
+
if (!from) return null;
|
|
890
|
+
const needManifest = (what: string): FaceManifest => {
|
|
891
|
+
if (!ctx.manifest) {
|
|
892
|
+
throw new CompileError(`bone "${spec.name}" takes its position from ${what}, which needs a cut manifest`);
|
|
893
|
+
}
|
|
894
|
+
return ctx.manifest;
|
|
895
|
+
};
|
|
896
|
+
if (from.anchor !== undefined) {
|
|
897
|
+
const manifest = needManifest(`the manifest anchor "${from.anchor}"`);
|
|
898
|
+
const anchor = manifest.anchors?.[from.anchor];
|
|
899
|
+
if (!anchor || anchor.length < 2) {
|
|
900
|
+
throw new CompileError(
|
|
901
|
+
`manifest anchors has no [x, y] for "${from.anchor}" (bone "${spec.name}" of rig "${ctx.rig.name}")`,
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
return [anchor[0], anchor[1]];
|
|
905
|
+
}
|
|
906
|
+
if (from.slotWindow !== undefined) {
|
|
907
|
+
const manifest = needManifest(`the window of slot "${from.slotWindow}"`);
|
|
908
|
+
const part = ctx.partBySlot.get(from.slotWindow);
|
|
909
|
+
if (!part) {
|
|
910
|
+
throw new CompileError(
|
|
911
|
+
`bone "${spec.name}" sits at the centre of slot "${from.slotWindow}", which this cut's manifest carries no part for`,
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
const win = partWindow(part, manifest);
|
|
915
|
+
return [win.x + win.w / 2, win.y + win.h / 2];
|
|
916
|
+
}
|
|
917
|
+
if (from.meshCenter !== undefined) {
|
|
918
|
+
needManifest(`the mesh centre of slot "${from.meshCenter}"`);
|
|
919
|
+
const centre = ctx.partBySlot.get(from.meshCenter)?.mesh?.center;
|
|
920
|
+
if (!centre) {
|
|
921
|
+
throw new CompileError(
|
|
922
|
+
`bone "${spec.name}" sits on the mesh centre of slot "${from.meshCenter}", which declares no mesh.center`,
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
return [centre[0], centre[1]];
|
|
926
|
+
}
|
|
927
|
+
return null;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** The setup rotation a bone declares, in Spine degrees, or null for none. */
|
|
931
|
+
function rotationOf(spec: RigBone, ctx: BoneContext): number | null {
|
|
932
|
+
const source = spec.from?.rotation;
|
|
933
|
+
if (source === 'axis') {
|
|
934
|
+
if (ctx.axisSpineDeg === null) {
|
|
935
|
+
throw new CompileError(`bone "${spec.name}" takes its rotation from the cut axis, which the manifest does not declare`);
|
|
936
|
+
}
|
|
937
|
+
return ctx.axisSpineDeg;
|
|
938
|
+
}
|
|
939
|
+
if (source === 'anchor') {
|
|
940
|
+
const key = spec.from!.anchor!;
|
|
941
|
+
const anchor = ctx.manifest?.anchors?.[key];
|
|
942
|
+
if (!anchor || anchor.length < 3) {
|
|
943
|
+
throw new CompileError(
|
|
944
|
+
`bone "${spec.name}" takes its rotation from anchor "${key}", which has no third element (a screen-space facing angle)`,
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
return screenToSpineDegrees(anchor[2]);
|
|
948
|
+
}
|
|
949
|
+
return spec.rotation ?? null;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// ---------------------------------------------------------------------------
|
|
953
|
+
// rig-declared attachments
|
|
954
|
+
// ---------------------------------------------------------------------------
|
|
955
|
+
|
|
956
|
+
interface AttachmentContext {
|
|
957
|
+
images: CompiledImage[];
|
|
958
|
+
bones: SpineBone[];
|
|
959
|
+
transforms: Map<string, BoneTransform>;
|
|
960
|
+
meshBones: Set<string>;
|
|
961
|
+
meshes: CompileResult['meshes'];
|
|
962
|
+
slotName: string;
|
|
963
|
+
anchorBone: string;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Build one attachment a rig spec authored, as opposed to one a manifest part
|
|
968
|
+
* produced.
|
|
969
|
+
*
|
|
970
|
+
* The types this refuses are refused BY NAME. The parser's own behaviour on an
|
|
971
|
+
* attachment type it does not know is to return null and drop it
|
|
972
|
+
* (`SkeletonJson.ts:653`), so passing an unimplemented type through would produce
|
|
973
|
+
* a skeleton missing an attachment nobody was told about.
|
|
974
|
+
*/
|
|
975
|
+
function buildRigAttachment(
|
|
976
|
+
att: RigAttachment,
|
|
977
|
+
placeholder: string,
|
|
978
|
+
where: string,
|
|
979
|
+
ctx: AttachmentContext,
|
|
980
|
+
): SpineAttachment {
|
|
981
|
+
const type = att.type ?? 'region';
|
|
982
|
+
if (type === 'region') return buildRigRegion(att as RigRegionAttachment, placeholder, where, ctx);
|
|
983
|
+
if (type === 'mesh') return buildRigMesh(att as RigMeshAttachment, where, ctx);
|
|
984
|
+
throw new NotImplementedError(
|
|
985
|
+
`${where}: attachment type "${String(type)}" is in the Spine 4.3 format and rigc does not emit it yet. ` +
|
|
986
|
+
'Implemented: region, mesh. docs/SPEC_COVERAGE.md part 1-6 lists what the type would have to carry.',
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function buildRigRegion(
|
|
991
|
+
att: RigRegionAttachment,
|
|
992
|
+
placeholder: string,
|
|
993
|
+
where: string,
|
|
994
|
+
ctx: AttachmentContext,
|
|
995
|
+
): SpineRegionAttachment {
|
|
996
|
+
const img = att.image === undefined ? null : ctx.images.find((im) => im.region === basename(att.image!, '.png'));
|
|
997
|
+
const width = att.width ?? img?.width;
|
|
998
|
+
const height = att.height ?? img?.height;
|
|
999
|
+
if (width === undefined || height === undefined) {
|
|
1000
|
+
// No parser default: an omission loads as NaN and every UV collapses, with
|
|
1001
|
+
// no error at all. So it is this or nothing.
|
|
1002
|
+
throw new CompileError(
|
|
1003
|
+
`${where}: a region needs width and height — give them, or give an "image" and rigc will measure the PNG`,
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
const out: SpineRegionAttachment = { width: r6(width), height: r6(height) };
|
|
1007
|
+
const region = att.image === undefined ? undefined : basename(att.image, '.png');
|
|
1008
|
+
if (att.path !== undefined) out.path = att.path;
|
|
1009
|
+
else if (region !== undefined && region !== placeholder) out.path = region;
|
|
1010
|
+
if (att.x !== undefined) out.x = r6(att.x);
|
|
1011
|
+
if (att.y !== undefined) out.y = r6(att.y);
|
|
1012
|
+
if (att.rotation !== undefined) out.rotation = r6(att.rotation);
|
|
1013
|
+
if (att.scaleX !== undefined) out.scaleX = r6(att.scaleX);
|
|
1014
|
+
if (att.scaleY !== undefined) out.scaleY = r6(att.scaleY);
|
|
1015
|
+
if (att.color !== undefined) out.color = att.color;
|
|
1016
|
+
return out;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Resolve an authored mesh's by-name weights into Spine's index run.
|
|
1021
|
+
*
|
|
1022
|
+
* 🚨 This is the whole point of the `weights` form. The run is
|
|
1023
|
+
* `boneCount, (boneIndex, bindX, bindY, weight) x n` per vertex and those
|
|
1024
|
+
* indices are positions in the emitted bone array — a thing the rig spec never
|
|
1025
|
+
* writes. Resolving them here, from names, is what makes "insert a bone" a
|
|
1026
|
+
* renumbering rather than a rebinding: the names still point at the same bones,
|
|
1027
|
+
* so the emitted indices move and the mesh does not.
|
|
1028
|
+
*
|
|
1029
|
+
* An unknown name is a `CompileError`, the same as a bone's `parent`, a slot's
|
|
1030
|
+
* `bone` or a constraint's `target`. The alternative — the raw form — cannot
|
|
1031
|
+
* refuse anything, because an index has no name to be wrong.
|
|
1032
|
+
*/
|
|
1033
|
+
function encodeNamedWeights(weights: RigMeshBinding[][], where: string, ctx: AttachmentContext): number[] {
|
|
1034
|
+
const out: number[] = [];
|
|
1035
|
+
weights.forEach((vertex, i) => {
|
|
1036
|
+
if (!Array.isArray(vertex) || vertex.length === 0) {
|
|
1037
|
+
throw new CompileError(`${where}: vertex ${i} has no bone bindings; a weighted vertex names at least one bone`);
|
|
1038
|
+
}
|
|
1039
|
+
out.push(vertex.length);
|
|
1040
|
+
for (const binding of vertex) {
|
|
1041
|
+
const index = ctx.bones.findIndex((b) => b.name === binding.bone);
|
|
1042
|
+
if (index < 0) {
|
|
1043
|
+
throw new CompileError(
|
|
1044
|
+
`${where}: vertex ${i} binds bone ${JSON.stringify(binding.bone)}, which the rig does not declare as a bone`,
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
out.push(index, r6(binding.x), r6(binding.y), r6(binding.weight));
|
|
1048
|
+
}
|
|
1049
|
+
});
|
|
1050
|
+
return out;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function buildRigMesh(att: RigMeshAttachment, where: string, ctx: AttachmentContext): SpineMeshAttachment {
|
|
1054
|
+
const authored =
|
|
1055
|
+
att.uvs !== undefined || att.triangles !== undefined || att.vertices !== undefined || att.weights !== undefined;
|
|
1056
|
+
if (authored && att.generator) {
|
|
1057
|
+
throw new CompileError(`${where}: a mesh is either authored geometry or a generator, never both`);
|
|
1058
|
+
}
|
|
1059
|
+
if (att.generator) return buildGeneratedMesh(att, att.generator, where, ctx);
|
|
1060
|
+
if (att.vertices && att.weights) {
|
|
1061
|
+
throw new CompileError(
|
|
1062
|
+
`${where}: a mesh gives geometry as "vertices" or as "weights", never both — "weights" is the by-name form of the same data`,
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
if (!att.uvs || !att.triangles || !(att.vertices || att.weights)) {
|
|
1066
|
+
throw new CompileError(`${where}: an authored mesh needs uvs, triangles and vertices or weights (or a "generator")`);
|
|
1067
|
+
}
|
|
1068
|
+
const uvCount = att.uvs.length;
|
|
1069
|
+
let vertices: number[];
|
|
1070
|
+
let boundBones: string[] = [];
|
|
1071
|
+
if (att.weights) {
|
|
1072
|
+
if (att.boneIndexing === 'raw') {
|
|
1073
|
+
throw new CompileError(`${where}: "boneIndexing": "raw" describes a "vertices" run; "weights" always binds by name`);
|
|
1074
|
+
}
|
|
1075
|
+
if (att.weights.length !== uvCount / 2) {
|
|
1076
|
+
throw new CompileError(
|
|
1077
|
+
`${where}: weights cover ${att.weights.length} vertices but there are ${uvCount / 2} uv pairs`,
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
vertices = encodeNamedWeights(att.weights, where, ctx);
|
|
1081
|
+
boundBones = [...new Set(att.weights.flat().map((b) => b.bone))];
|
|
1082
|
+
} else {
|
|
1083
|
+
const raw = att.vertices!;
|
|
1084
|
+
// An unweighted mesh is one x,y per uv pair. It names no bone, so there is
|
|
1085
|
+
// nothing here to rebind and nothing to opt into.
|
|
1086
|
+
const weighted = raw.length !== uvCount;
|
|
1087
|
+
if (weighted && att.boneIndexing !== 'raw') {
|
|
1088
|
+
throw new CompileError(
|
|
1089
|
+
`${where}: this mesh's "vertices" is a weighted run, whose bone INDEXES point into the emitted bone array — ` +
|
|
1090
|
+
'a list the spec never writes, so inserting a bone rebinds every vertex in silence. ' +
|
|
1091
|
+
'Give the bindings by name as "weights": [[{ "bone": …, "x": …, "y": …, "weight": … }, …], …], ' +
|
|
1092
|
+
'or say "boneIndexing": "raw" on this attachment to keep the index form deliberately.',
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
vertices = raw.map(r6);
|
|
1096
|
+
if (weighted) {
|
|
1097
|
+
const names = new Set<string>();
|
|
1098
|
+
for (let i = 0; i < raw.length; ) {
|
|
1099
|
+
const n = raw[i++];
|
|
1100
|
+
for (let k = 0; k < n; k++, i += 4) {
|
|
1101
|
+
const bone = ctx.bones[raw[i]];
|
|
1102
|
+
if (bone) names.add(bone.name);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
boundBones = [...names];
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
const out: SpineMeshAttachment = {
|
|
1109
|
+
type: 'mesh',
|
|
1110
|
+
uvs: att.uvs.map(r6),
|
|
1111
|
+
triangles: att.triangles,
|
|
1112
|
+
vertices,
|
|
1113
|
+
hull: att.hull ?? 0,
|
|
1114
|
+
width: r6(att.width ?? 0),
|
|
1115
|
+
height: r6(att.height ?? 0),
|
|
1116
|
+
};
|
|
1117
|
+
if (att.path !== undefined) out.path = att.path;
|
|
1118
|
+
if (att.edges !== undefined) out.edges = att.edges;
|
|
1119
|
+
if (att.color !== undefined) out.color = att.color;
|
|
1120
|
+
// Register it as `authored`: geometry rigc did not build and whose topology it
|
|
1121
|
+
// therefore gets to assume nothing about. The generator-topology assertions
|
|
1122
|
+
// read this and skip rather than measuring a ring that was never a ring.
|
|
1123
|
+
ctx.meshBones.add(ctx.anchorBone);
|
|
1124
|
+
for (const name of boundBones) ctx.meshBones.add(name);
|
|
1125
|
+
ctx.meshes.push({
|
|
1126
|
+
slot: ctx.slotName,
|
|
1127
|
+
kind: 'authored',
|
|
1128
|
+
attachments: [ctx.slotName],
|
|
1129
|
+
vertices: uvCount / 2,
|
|
1130
|
+
triangles: att.triangles.length / 3,
|
|
1131
|
+
bones: boundBones.length ? boundBones : [ctx.anchorBone],
|
|
1132
|
+
});
|
|
1133
|
+
return out;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* Invoke a `src/mesh.ts` builder from rig-spec data.
|
|
1138
|
+
*
|
|
1139
|
+
* ⚠️ This is the path a skeleton with NO cut manifest takes. A cut that has one
|
|
1140
|
+
* invokes the same builders through the manifest's `mesh` block instead, because
|
|
1141
|
+
* everything the builders need — the mask contour, the aperture centre, the part
|
|
1142
|
+
* window — is measured art, and measured art has exactly one home.
|
|
1143
|
+
*/
|
|
1144
|
+
function buildGeneratedMesh(
|
|
1145
|
+
att: RigMeshAttachment,
|
|
1146
|
+
generator: NonNullable<RigMeshAttachment['generator']>,
|
|
1147
|
+
where: string,
|
|
1148
|
+
ctx: AttachmentContext,
|
|
1149
|
+
): SpineMeshAttachment {
|
|
1150
|
+
if (generator.kind === 'contour') {
|
|
1151
|
+
throw new NotImplementedError(
|
|
1152
|
+
`${where}: the "contour" generator would triangulate a part's own alpha mask, and src/mesh.ts has no triangulator — ` +
|
|
1153
|
+
'it holds buildRingMesh and buildRibbonMesh only',
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
const controls = generator.kind === 'ring' ? generator.controls : generator.chain;
|
|
1157
|
+
const refFor = (name: string): MeshBoneRef => {
|
|
1158
|
+
const index = ctx.bones.findIndex((b) => b.name === name);
|
|
1159
|
+
if (index < 0) throw new CompileError(`${where}: mesh bone "${name}" is not in the rig's bone list`);
|
|
1160
|
+
const m = ctx.transforms.get(name);
|
|
1161
|
+
if (!m) throw new CompileError(`${where}: no setup transform for mesh bone "${name}"`);
|
|
1162
|
+
return { index, toBind: (wx, wy) => toBoneLocal(m, wx, wy) };
|
|
1163
|
+
};
|
|
1164
|
+
let geometry;
|
|
1165
|
+
try {
|
|
1166
|
+
geometry =
|
|
1167
|
+
generator.kind === 'ribbon'
|
|
1168
|
+
? buildRibbonMesh({ size: generator.size, rows: generator.rows, chainCount: generator.chain.length })
|
|
1169
|
+
: buildRingMesh({
|
|
1170
|
+
hull: generator.hull,
|
|
1171
|
+
center: generator.center,
|
|
1172
|
+
inner: generator.inner,
|
|
1173
|
+
size: generator.size,
|
|
1174
|
+
bias: generator.bias,
|
|
1175
|
+
});
|
|
1176
|
+
} catch (err) {
|
|
1177
|
+
if (err instanceof MeshError) throw new CompileError(`${where}: ${err.message}`);
|
|
1178
|
+
throw err;
|
|
1179
|
+
}
|
|
1180
|
+
// The generator works in part-local pixels, y down. Without a manifest there is
|
|
1181
|
+
// no crop to flip against, so the part window is centred on its own slot bone.
|
|
1182
|
+
const [w, h] = generator.size;
|
|
1183
|
+
const anchor = ctx.transforms.get(ctx.anchorBone);
|
|
1184
|
+
if (!anchor) throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" has no setup transform`);
|
|
1185
|
+
const vertices = encodeWeightedVertices(
|
|
1186
|
+
geometry,
|
|
1187
|
+
(px, py) => [r6(anchor.worldX + px - w / 2), r6(anchor.worldY + h / 2 - py)],
|
|
1188
|
+
{ anchor: refFor(ctx.anchorBone), controls: controls.map(refFor) },
|
|
1189
|
+
);
|
|
1190
|
+
ctx.meshBones.add(ctx.anchorBone);
|
|
1191
|
+
for (const name of controls) ctx.meshBones.add(name);
|
|
1192
|
+
ctx.meshes.push({
|
|
1193
|
+
slot: ctx.slotName,
|
|
1194
|
+
kind: geometry.kind,
|
|
1195
|
+
attachments: [ctx.slotName],
|
|
1196
|
+
vertices: geometry.uvs.length / 2,
|
|
1197
|
+
triangles: geometry.triangles.length / 3,
|
|
1198
|
+
bones: [ctx.anchorBone, ...controls],
|
|
1199
|
+
});
|
|
1200
|
+
const out: SpineMeshAttachment = {
|
|
1201
|
+
type: 'mesh',
|
|
1202
|
+
uvs: geometry.uvs,
|
|
1203
|
+
triangles: geometry.triangles,
|
|
1204
|
+
vertices,
|
|
1205
|
+
hull: geometry.hullVertices,
|
|
1206
|
+
width: r6(w),
|
|
1207
|
+
height: r6(h),
|
|
1208
|
+
};
|
|
1209
|
+
if (att.path !== undefined) out.path = att.path;
|
|
1210
|
+
if (att.color !== undefined) out.color = att.color;
|
|
1211
|
+
return out;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// ---------------------------------------------------------------------------
|
|
1215
|
+
// rig-declared constraints
|
|
1216
|
+
// ---------------------------------------------------------------------------
|
|
1217
|
+
|
|
1218
|
+
/** The rig-constraint shape as this file consumes it: a name, a type, and fields. */
|
|
1219
|
+
type RigConstraintInput = { name: string; type: string } & Record<string, unknown>;
|
|
1220
|
+
|
|
1221
|
+
/** The six property names a transform constraint may map between (`:241`, `:521`). */
|
|
1222
|
+
const TRANSFORM_PROPERTIES = ['rotate', 'x', 'y', 'scaleX', 'scaleY', 'shearY'];
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* 4.3 puts every constraint in one array and branches on `type`. An entry whose
|
|
1226
|
+
* type matches no case is dropped with no error and no `default:` branch, so an
|
|
1227
|
+
* unimplemented type is refused here by name rather than emitted and lost.
|
|
1228
|
+
*/
|
|
1229
|
+
function buildRigConstraint(spec: RigConstraintInput, boneNames: Set<string>): SpineConstraint {
|
|
1230
|
+
const where = `rig constraint "${spec.name}"`;
|
|
1231
|
+
const needBone = (name: unknown, field: string): string => {
|
|
1232
|
+
if (typeof name !== 'string' || !boneNames.has(name)) {
|
|
1233
|
+
throw new CompileError(`${where}: ${field} names ${JSON.stringify(name)}, which the rig does not declare as a bone`);
|
|
1234
|
+
}
|
|
1235
|
+
return name;
|
|
1236
|
+
};
|
|
1237
|
+
const out: SpineConstraint = { name: spec.name, type: spec.type };
|
|
1238
|
+
const copy = (fields: readonly string[]) => {
|
|
1239
|
+
for (const field of fields) {
|
|
1240
|
+
const v = spec[field];
|
|
1241
|
+
if (v !== undefined) out[field] = typeof v === 'number' ? r6(v) : v;
|
|
1242
|
+
}
|
|
1243
|
+
};
|
|
1244
|
+
const boneList = (): string[] => {
|
|
1245
|
+
const list = spec.bones;
|
|
1246
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
1247
|
+
throw new CompileError(`${where}: a ${spec.type} constraint needs a non-empty "bones" array`);
|
|
1248
|
+
}
|
|
1249
|
+
return list.map((name, i) => needBone(name, `bones[${i}]`));
|
|
1250
|
+
};
|
|
1251
|
+
|
|
1252
|
+
if (spec.type === 'ik') {
|
|
1253
|
+
out.bones = boneList();
|
|
1254
|
+
out.target = needBone(spec.target, 'target');
|
|
1255
|
+
copy(['scaleY', 'mix', 'softness', 'bendPositive', 'compress', 'stretch', 'skin']);
|
|
1256
|
+
return out;
|
|
1257
|
+
}
|
|
1258
|
+
if (spec.type === 'transform') {
|
|
1259
|
+
out.bones = boneList();
|
|
1260
|
+
out.source = needBone(spec.source, 'source');
|
|
1261
|
+
const properties = spec.properties as Record<string, { to?: Record<string, unknown> }> | undefined;
|
|
1262
|
+
for (const [from, entry] of Object.entries(properties ?? {})) {
|
|
1263
|
+
// The parser THROWS on a name outside the six, which is one of the few
|
|
1264
|
+
// places in this format that does not fail silently — but it throws at load
|
|
1265
|
+
// time, in the consumer's process, and that is late.
|
|
1266
|
+
if (!TRANSFORM_PROPERTIES.includes(from)) {
|
|
1267
|
+
throw new CompileError(`${where}: properties has "${from}"; known: ${TRANSFORM_PROPERTIES.join(', ')}`);
|
|
1268
|
+
}
|
|
1269
|
+
for (const to of Object.keys(entry?.to ?? {})) {
|
|
1270
|
+
if (!TRANSFORM_PROPERTIES.includes(to)) {
|
|
1271
|
+
throw new CompileError(`${where}: properties.${from}.to has "${to}"; known: ${TRANSFORM_PROPERTIES.join(', ')}`);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
if (properties !== undefined) out.properties = properties;
|
|
1276
|
+
copy([
|
|
1277
|
+
'localSource',
|
|
1278
|
+
'localTarget',
|
|
1279
|
+
'additive',
|
|
1280
|
+
'clamp',
|
|
1281
|
+
'rotation',
|
|
1282
|
+
'x',
|
|
1283
|
+
'y',
|
|
1284
|
+
'scaleX',
|
|
1285
|
+
'scaleY',
|
|
1286
|
+
'shearY',
|
|
1287
|
+
'mixRotate',
|
|
1288
|
+
'mixX',
|
|
1289
|
+
'mixY',
|
|
1290
|
+
'mixScaleX',
|
|
1291
|
+
'mixScaleY',
|
|
1292
|
+
'mixShearY',
|
|
1293
|
+
'skin',
|
|
1294
|
+
]);
|
|
1295
|
+
return out;
|
|
1296
|
+
}
|
|
1297
|
+
if (spec.type === 'physics') {
|
|
1298
|
+
out.bone = needBone(spec.bone, 'bone');
|
|
1299
|
+
copy([
|
|
1300
|
+
'x',
|
|
1301
|
+
'y',
|
|
1302
|
+
'rotate',
|
|
1303
|
+
'scaleX',
|
|
1304
|
+
'shearX',
|
|
1305
|
+
'scaleY',
|
|
1306
|
+
'limit',
|
|
1307
|
+
'fps',
|
|
1308
|
+
'inertia',
|
|
1309
|
+
'strength',
|
|
1310
|
+
'damping',
|
|
1311
|
+
'mass',
|
|
1312
|
+
'wind',
|
|
1313
|
+
'gravity',
|
|
1314
|
+
'mix',
|
|
1315
|
+
'inertiaGlobal',
|
|
1316
|
+
'strengthGlobal',
|
|
1317
|
+
'dampingGlobal',
|
|
1318
|
+
'massGlobal',
|
|
1319
|
+
'windGlobal',
|
|
1320
|
+
'gravityGlobal',
|
|
1321
|
+
'mixGlobal',
|
|
1322
|
+
'skin',
|
|
1323
|
+
]);
|
|
1324
|
+
return out;
|
|
1325
|
+
}
|
|
1326
|
+
throw new NotImplementedError(
|
|
1327
|
+
`${where}: constraint type "${String(spec.type)}" is in the Spine 4.3 format and rigc does not emit it yet. ` +
|
|
1328
|
+
'Implemented: ik, transform, physics. Neither path nor slider appears anywhere in the benchmark corpus ' +
|
|
1329
|
+
'(docs/SPEC_COVERAGE.md part 4-2).',
|
|
1330
|
+
);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* Collect what the artifact cannot say about itself.
|
|
1335
|
+
*
|
|
1336
|
+
* Nothing in skeleton JSON records that a mesh is a ribbon, that a bone's
|
|
1337
|
+
* subtree is authored in axis space, or that one parentage is forbidden. Those
|
|
1338
|
+
* are rig facts, so the compiler hands them to the validator instead of letting
|
|
1339
|
+
* it guess — and a mutant stays honest because it edits the artifact while this
|
|
1340
|
+
* block keeps saying what the rig was supposed to be.
|
|
1341
|
+
*/
|
|
1342
|
+
function buildRigInfo(
|
|
1343
|
+
rig: RigSpec,
|
|
1344
|
+
bones: SpineBone[],
|
|
1345
|
+
meshes: CompileResult['meshes'],
|
|
1346
|
+
manifest: FaceManifest | null,
|
|
1347
|
+
): RigInfo {
|
|
1348
|
+
const axisBone = rig.invariants?.axisBone ?? null;
|
|
1349
|
+
if (axisBone !== null && !bones.some((b) => b.name === axisBone)) {
|
|
1350
|
+
throw new CompileError(`rig "${rig.name}" names "${axisBone}" as its axis bone, which it does not declare`);
|
|
1351
|
+
}
|
|
1352
|
+
const axisSubtree: string[] = [];
|
|
1353
|
+
if (axisBone) {
|
|
1354
|
+
const parentOf = new Map(bones.map((b) => [b.name, b.parent ?? null]));
|
|
1355
|
+
for (const bone of bones) {
|
|
1356
|
+
for (let cursor: string | null = bone.name; cursor; cursor = parentOf.get(cursor) ?? null) {
|
|
1357
|
+
if (cursor !== axisBone) continue;
|
|
1358
|
+
axisSubtree.push(bone.name);
|
|
1359
|
+
break;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
const meshKinds: Record<string, 'ring' | 'ribbon' | 'authored'> = {};
|
|
1364
|
+
for (const mesh of meshes) meshKinds[mesh.slot] = mesh.kind;
|
|
1365
|
+
// Inward, in Spine world. Off-axis keys (the mass bone usually hangs outside
|
|
1366
|
+
// the axis subtree) have to be projected onto it before they can be compared
|
|
1367
|
+
// with travel along the axis.
|
|
1368
|
+
const spineDeg = manifest?.axis ? screenToSpineDegrees(manifest.axis.deg) : null;
|
|
1369
|
+
const inwardUnit: [number, number] | null =
|
|
1370
|
+
spineDeg === null ? null : [r6(Math.cos((spineDeg * Math.PI) / 180)), r6(Math.sin((spineDeg * Math.PI) / 180))];
|
|
1371
|
+
const contactDepth = manifest?.stroke?.contact_depth ?? null;
|
|
1372
|
+
if (contactDepth !== null && !(contactDepth > 0)) {
|
|
1373
|
+
throw new CompileError(`manifest stroke.contact_depth is ${contactDepth}; it must be a positive number of axis pixels`);
|
|
1374
|
+
}
|
|
1375
|
+
const capCeiling = manifest?.stroke?.cap_containment_ceiling ?? null;
|
|
1376
|
+
if (capCeiling !== null && !(capCeiling > 0)) {
|
|
1377
|
+
throw new CompileError(
|
|
1378
|
+
`manifest stroke.cap_containment_ceiling is ${capCeiling}; it must be a positive number of axis pixels (use null for "not measurable on this cut")`,
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1381
|
+
return {
|
|
1382
|
+
archetype: rig.name,
|
|
1383
|
+
axisBone,
|
|
1384
|
+
axisSubtree,
|
|
1385
|
+
detached: (rig.invariants?.detached ?? []).map((d) => [d.bone, d.notUnder] as [string, string]),
|
|
1386
|
+
slotOrder: rig.slots.length ? rig.slots.map((s) => s.name) : null,
|
|
1387
|
+
meshKinds,
|
|
1388
|
+
meshSlotBudget: rig.invariants?.meshSlots ?? null,
|
|
1389
|
+
meshTriangleBudget: rig.invariants?.meshTriangles ?? null,
|
|
1390
|
+
contactDepth,
|
|
1391
|
+
capContainmentCeiling: capCeiling,
|
|
1392
|
+
massBone: rig.invariants?.massBone ?? null,
|
|
1393
|
+
inwardUnit,
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* A manifest that disagrees with itself is the cheapest bug to catch and the
|
|
1399
|
+
* worst to debug three files later, so the axis unit vector is checked against
|
|
1400
|
+
* the axis angle before anything is built from either.
|
|
1401
|
+
*/
|
|
1402
|
+
function checkAxisSelfConsistency(manifest: FaceManifest): void {
|
|
1403
|
+
if (!manifest.axis) return;
|
|
1404
|
+
const { deg, unit } = manifest.axis;
|
|
1405
|
+
if (!Array.isArray(unit) || unit.length !== 2) {
|
|
1406
|
+
throw new CompileError(`manifest axis.unit must be [x, y], got ${JSON.stringify(unit)}`);
|
|
1407
|
+
}
|
|
1408
|
+
const ex = Math.cos((deg * Math.PI) / 180);
|
|
1409
|
+
const ey = Math.sin((deg * Math.PI) / 180);
|
|
1410
|
+
if (Math.hypot(unit[0] - ex, unit[1] - ey) > 1e-3) {
|
|
1411
|
+
throw new CompileError(
|
|
1412
|
+
`manifest axis.unit [${unit[0]}, ${unit[1]}] does not match axis.deg ${deg} (expected [${r6(ex)}, ${r6(ey)}])`,
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/**
|
|
1418
|
+
* Place a rigid region on its bone.
|
|
1419
|
+
*
|
|
1420
|
+
* Two offsets are folded in here. The attachment is centred on the part window
|
|
1421
|
+
* rather than on the bone, because several slots may share one bone — a part and
|
|
1422
|
+
* its motion-blur variant, an occluder and what pools against it — while their
|
|
1423
|
+
* windows sit in different places. And the attachment's own `rotation` cancels
|
|
1424
|
+
* the bone's world rotation, because a plate is authored in screen space:
|
|
1425
|
+
* without it every slot hanging off a rotated axis bone would render tilted by
|
|
1426
|
+
* the cut's axis angle.
|
|
1427
|
+
*
|
|
1428
|
+
* On an unrotated bone sitting at its window centre both terms are zero and the
|
|
1429
|
+
* fields are omitted, which is why a formation with no axis emits the same
|
|
1430
|
+
* bytes it always did.
|
|
1431
|
+
*/
|
|
1432
|
+
function placeRegion(
|
|
1433
|
+
part: FaceManifestPart,
|
|
1434
|
+
manifest: FaceManifest,
|
|
1435
|
+
bone: BoneTransform,
|
|
1436
|
+
img: CompiledImage,
|
|
1437
|
+
): SpineRegionAttachment {
|
|
1438
|
+
const win = partWindow(part, manifest);
|
|
1439
|
+
// width/height are NOT optional: omitting them loads as NaN with no error.
|
|
1440
|
+
// The compiler fills them from the PNG.
|
|
1441
|
+
const att: SpineRegionAttachment = { width: img.width, height: img.height };
|
|
1442
|
+
const [ax, ay] = toBoneLocal(bone, win.x + win.w / 2, cropToSpineY(win.y + win.h / 2, manifest.crop.h));
|
|
1443
|
+
if (ax !== 0) att.x = ax;
|
|
1444
|
+
if (ay !== 0) att.y = ay;
|
|
1445
|
+
const rotation = normaliseDegrees(-bone.worldRotation);
|
|
1446
|
+
if (rotation !== 0) att.rotation = rotation;
|
|
1447
|
+
return att;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/**
|
|
1451
|
+
* Build one mesh for a manifest part and encode its weighted vertices.
|
|
1452
|
+
*
|
|
1453
|
+
* Two generators, one call site. A `ring` pins its two outer rings and moves only
|
|
1454
|
+
* the aperture; a `ribbon` pins its entry row and lets the chain stretch the rest.
|
|
1455
|
+
* Which one a part gets is manifest data, not a guess — the compiler will not
|
|
1456
|
+
* infer a deformation model from a polygon's shape.
|
|
1457
|
+
*/
|
|
1458
|
+
function buildMesh(
|
|
1459
|
+
part: FaceManifestPart,
|
|
1460
|
+
manifest: FaceManifest,
|
|
1461
|
+
bones: SpineBone[],
|
|
1462
|
+
transforms: Map<string, BoneTransform>,
|
|
1463
|
+
anchorName: string,
|
|
1464
|
+
): { attachment: SpineMeshAttachment; kind: 'ring' | 'ribbon' } {
|
|
1465
|
+
const spec = part.mesh!;
|
|
1466
|
+
const kind = spec.kind ?? 'ring';
|
|
1467
|
+
const win = partWindow(part, manifest);
|
|
1468
|
+
const cropH = manifest.crop.h;
|
|
1469
|
+
const controls = meshControlBones(part);
|
|
1470
|
+
|
|
1471
|
+
const refFor = (name: string): MeshBoneRef => {
|
|
1472
|
+
const index = bones.findIndex((b) => b.name === name);
|
|
1473
|
+
if (index < 0) throw new CompileError(`internal: mesh bone "${name}" is not in the bone list`);
|
|
1474
|
+
const m = transforms.get(name);
|
|
1475
|
+
if (!m) throw new CompileError(`internal: no setup transform for mesh bone "${name}"`);
|
|
1476
|
+
return { index, toBind: (wx, wy) => toBoneLocal(m, wx, wy) };
|
|
1477
|
+
};
|
|
1478
|
+
|
|
1479
|
+
let geometry;
|
|
1480
|
+
try {
|
|
1481
|
+
if (kind === 'ribbon') {
|
|
1482
|
+
geometry = buildRibbonMesh({ size: [win.w, win.h], rows: spec.rows!, chainCount: controls.length });
|
|
1483
|
+
} else {
|
|
1484
|
+
const centre: [number, number] = [spec.center![0] - win.x, spec.center![1] - win.y];
|
|
1485
|
+
// Control bones enter the ring builder as ANGLES about the aperture, taken
|
|
1486
|
+
// from where the rig actually put them. The alternative — a per-bone angle
|
|
1487
|
+
// in the manifest — would let the declared angle drift away from the
|
|
1488
|
+
// declared position, and then the ring would deform toward a bone that is
|
|
1489
|
+
// somewhere else.
|
|
1490
|
+
//
|
|
1491
|
+
// A single control bone needs no angle at all: it owns the whole ring, and
|
|
1492
|
+
// the face rig deliberately puts it ON the aperture centre, where a radial
|
|
1493
|
+
// direction does not exist.
|
|
1494
|
+
const controlAngles =
|
|
1495
|
+
controls.length > 1
|
|
1496
|
+
? controls.map((name) => {
|
|
1497
|
+
const m = transforms.get(name);
|
|
1498
|
+
if (!m) throw new CompileError(`internal: no setup transform for control bone "${name}"`);
|
|
1499
|
+
const dx = m.worldX - spec.center![0];
|
|
1500
|
+
const dy = cropH - m.worldY - spec.center![1];
|
|
1501
|
+
if (Math.hypot(dx, dy) < 1e-6) {
|
|
1502
|
+
throw new CompileError(
|
|
1503
|
+
`control bone "${name}" sits on the aperture centre of slot "${part.slot}", so it has no radial direction`,
|
|
1504
|
+
);
|
|
1505
|
+
}
|
|
1506
|
+
return (Math.atan2(dy, dx) * 180) / Math.PI;
|
|
1507
|
+
})
|
|
1508
|
+
: undefined;
|
|
1509
|
+
geometry = buildRingMesh({
|
|
1510
|
+
hull: (part.polygon ?? []).map(([x, y]) => [x - win.x, y - win.y] as [number, number]),
|
|
1511
|
+
center: centre,
|
|
1512
|
+
inner: spec.inner!,
|
|
1513
|
+
size: [win.w, win.h],
|
|
1514
|
+
bias: spec.bias ? { axis_deg: spec.bias.axis_deg, ramp: spec.bias.ramp } : undefined,
|
|
1515
|
+
controlAngles,
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1518
|
+
} catch (err) {
|
|
1519
|
+
if (err instanceof MeshError) throw new CompileError(`slot "${part.slot}" mesh: ${err.message}`);
|
|
1520
|
+
throw err;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
const vertices = encodeWeightedVertices(
|
|
1524
|
+
geometry,
|
|
1525
|
+
(px, py) => [r6(win.x + px), r6(cropToSpineY(win.y + py, cropH))],
|
|
1526
|
+
{ anchor: refFor(anchorName), controls: controls.map(refFor) },
|
|
1527
|
+
);
|
|
1528
|
+
|
|
1529
|
+
return {
|
|
1530
|
+
kind: geometry.kind,
|
|
1531
|
+
attachment: {
|
|
1532
|
+
type: 'mesh',
|
|
1533
|
+
uvs: geometry.uvs,
|
|
1534
|
+
triangles: geometry.triangles,
|
|
1535
|
+
vertices,
|
|
1536
|
+
hull: geometry.hullVertices,
|
|
1537
|
+
width: win.w,
|
|
1538
|
+
height: win.h,
|
|
1539
|
+
},
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/**
|
|
1544
|
+
* The raw-curve escape hatch: absolute (time, value) control points, verbatim.
|
|
1545
|
+
*
|
|
1546
|
+
* ⭐ Named easings stay the recommended path: a handle set with
|
|
1547
|
+
* a name is reusable, reviewable and retargetable, and it is what makes a motion
|
|
1548
|
+
* spec readable as intent rather than as numbers. But a named easing can only say
|
|
1549
|
+
* "the same shape, everywhere", and an editor export says a different shape per
|
|
1550
|
+
* key per channel — rung 3 of the benchmark ladder carries 54 bezier keys and no
|
|
1551
|
+
* two of them share handles. Refusing to express that would not make rigc's
|
|
1552
|
+
* output better; it would make rigc unable to state what Spine's format holds,
|
|
1553
|
+
* which is the same blocker as the bone tree being code, one layer down.
|
|
1554
|
+
*
|
|
1555
|
+
* So this is the escape hatch, and it is shaped like one: the numbers are the
|
|
1556
|
+
* file's own, checked for length and finiteness and passed through. What it is
|
|
1557
|
+
* NOT is a second way to write an easing — a key may carry `ease` or `curve`,
|
|
1558
|
+
* never both.
|
|
1559
|
+
*
|
|
1560
|
+
* ⚠️ These are ABSOLUTE (time, value) points, not the normalised graph-view
|
|
1561
|
+
* handles an editor shows. Writing the handles here would load without error and
|
|
1562
|
+
* produce a different curve, which is exactly the
|
|
1563
|
+
* trap `bezierForChannel` exists to keep authors out of.
|
|
1564
|
+
*/
|
|
1565
|
+
function rawCurve(curve: number[] | 'stepped', channels: number, where: string, at: string): number[] | 'stepped' {
|
|
1566
|
+
if (curve === 'stepped') return 'stepped';
|
|
1567
|
+
if (!Array.isArray(curve)) throw new CompileError(`${where} (t=${at}): curve must be an array or "stepped"`);
|
|
1568
|
+
if (curve.length !== channels * 4) {
|
|
1569
|
+
// A short array multiplies `undefined` into the cubic and yields NaN with no
|
|
1570
|
+
// error at all — case 6g, and the reason A05 exists.
|
|
1571
|
+
throw new CompileError(
|
|
1572
|
+
`${where} (t=${at}): raw curve has ${curve.length} numbers, this timeline needs ${channels} channel(s) x 4 = ${channels * 4}`,
|
|
1573
|
+
);
|
|
1574
|
+
}
|
|
1575
|
+
for (const n of curve) {
|
|
1576
|
+
if (typeof n !== 'number' || !Number.isFinite(n)) {
|
|
1577
|
+
throw new CompileError(`${where} (t=${at}): raw curve holds a non-finite value ${JSON.stringify(n)}`);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
return curve.map(r6);
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* Bone timelines. Same curve rule as the slot tracks — four numbers per value
|
|
1585
|
+
* channel, in field order — but the identity value differs per property, so a
|
|
1586
|
+
* key that matches setup is still emitted explicitly rather than omitted. An
|
|
1587
|
+
* omitted field is not "no change"; it is "the setup value", which is the same
|
|
1588
|
+
* thing only by accident.
|
|
1589
|
+
*/
|
|
1590
|
+
function compileValueTrack(
|
|
1591
|
+
track: MotionTrack,
|
|
1592
|
+
motion: MotionSpec,
|
|
1593
|
+
animName: string,
|
|
1594
|
+
duration: number,
|
|
1595
|
+
target: string,
|
|
1596
|
+
shift: number,
|
|
1597
|
+
shapes: Record<string, { fields: string[]; identity: number[] }>,
|
|
1598
|
+
kind: string,
|
|
1599
|
+
): SpineTimelineKey[] {
|
|
1600
|
+
const shape = shapes[track.property];
|
|
1601
|
+
if (!shape) throw new CompileError(`animation "${animName}": ${kind} "${target}" has no property "${track.property}"`);
|
|
1602
|
+
const where = `animation "${animName}" ${kind} "${target}" ${track.property}`;
|
|
1603
|
+
if (!track.keys.length) throw new CompileError(`${where}: no keys`);
|
|
1604
|
+
|
|
1605
|
+
const out: SpineTimelineKey[] = [];
|
|
1606
|
+
for (let i = 0; i < track.keys.length; i++) {
|
|
1607
|
+
const key = track.keys[i];
|
|
1608
|
+
const next = track.keys[i + 1];
|
|
1609
|
+
const time = r6(key.t + shift);
|
|
1610
|
+
if (i > 0 && time <= (out[i - 1].time as number)) {
|
|
1611
|
+
throw new CompileError(`${where}: key times must strictly increase (at t=${key.t})`);
|
|
1612
|
+
}
|
|
1613
|
+
checkKeyTime(where, time, key.t, duration);
|
|
1614
|
+
// A no-field timeline (`reset`) is an event: the key IS the value, so it
|
|
1615
|
+
// carries none. Anything else must match the field count exactly.
|
|
1616
|
+
if (shape.fields.length === 0) {
|
|
1617
|
+
if (key.v !== null) throw new CompileError(`${where}: this timeline takes no value; use null`);
|
|
1618
|
+
if (key.ease) throw new CompileError(`${where}: an event timeline cannot carry an easing`);
|
|
1619
|
+
out.push({ time });
|
|
1620
|
+
continue;
|
|
1621
|
+
}
|
|
1622
|
+
if (!Array.isArray(key.v) || key.v.length !== shape.fields.length) {
|
|
1623
|
+
throw new CompileError(`${where}: key value must be an array of ${shape.fields.length} number(s)`);
|
|
1624
|
+
}
|
|
1625
|
+
const entry: SpineTimelineKey = { time };
|
|
1626
|
+
shape.fields.forEach((field, c) => {
|
|
1627
|
+
const v = key.v as number[];
|
|
1628
|
+
if (!Number.isFinite(v[c])) throw new CompileError(`${where}: non-finite value ${String(v[c])}`);
|
|
1629
|
+
entry[field] = r6(v[c]);
|
|
1630
|
+
});
|
|
1631
|
+
if (key.ease !== undefined && key.curve !== undefined) {
|
|
1632
|
+
throw new CompileError(`${where}: a key carries both a named easing and a raw curve; pick one`);
|
|
1633
|
+
}
|
|
1634
|
+
if (key.curve !== undefined) {
|
|
1635
|
+
if (!next) throw new CompileError(`${where}: last key carries a curve but has nothing to ease to`);
|
|
1636
|
+
entry.curve = rawCurve(key.curve, shape.fields.length, where, String(key.t));
|
|
1637
|
+
} else if (key.ease && next) {
|
|
1638
|
+
if (key.ease === 'stepped') {
|
|
1639
|
+
entry.curve = 'stepped';
|
|
1640
|
+
} else {
|
|
1641
|
+
const handles = motion.easings?.[key.ease];
|
|
1642
|
+
if (!handles) throw new CompileError(`${where}: unknown easing "${key.ease}"`);
|
|
1643
|
+
if (!Array.isArray(next.v)) throw new CompileError(`${where}: next key value must be an array`);
|
|
1644
|
+
const t2 = r6(next.t + shift);
|
|
1645
|
+
const curve: number[] = [];
|
|
1646
|
+
for (let c = 0; c < shape.fields.length; c++) {
|
|
1647
|
+
curve.push(...bezierForChannel(handles, time, t2, (key.v as number[])[c], (next.v as number[])[c]));
|
|
1648
|
+
}
|
|
1649
|
+
entry.curve = curve;
|
|
1650
|
+
}
|
|
1651
|
+
} else if (key.ease && !next) {
|
|
1652
|
+
throw new CompileError(`${where}: last key carries an easing but has nothing to ease to`);
|
|
1653
|
+
}
|
|
1654
|
+
out.push(entry);
|
|
1655
|
+
}
|
|
1656
|
+
return out;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
/**
|
|
1660
|
+
* The whole-animation draw-order timeline (`animations.<a>.drawOrder`).
|
|
1661
|
+
*
|
|
1662
|
+
* ⭐ Four refusals here, and three of them exist because `readDrawOrder`
|
|
1663
|
+
* (SkeletonJson.ts:1336-1374) rebuilds the permutation with a **forward-only**
|
|
1664
|
+
* cursor over the setup order:
|
|
1665
|
+
*
|
|
1666
|
+
* ```
|
|
1667
|
+
* while (originalIndex !== index) unchanged[unchangedIndex++] = originalIndex++;
|
|
1668
|
+
* drawOrder[originalIndex + offsetMap.offset] = originalIndex++;
|
|
1669
|
+
* ```
|
|
1670
|
+
*
|
|
1671
|
+
* 1. **Offsets are emitted in slot order.** An entry whose slot sits EARLIER
|
|
1672
|
+
* than the previous entry's can never make `originalIndex` equal `index`
|
|
1673
|
+
* again, so that loop runs away — an artifact that hangs the loader rather
|
|
1674
|
+
* than loading wrong. The author states a set of moves; the array order in
|
|
1675
|
+
* the file is the parser's requirement and not a decision, so rigc sorts
|
|
1676
|
+
* rather than making every caller remember. Deterministic: the key is the
|
|
1677
|
+
* emitted slot index.
|
|
1678
|
+
* 2. **One slot per key.** Two entries for the same slot means two writes at
|
|
1679
|
+
* one cursor position; the second silently wins and the first slot's place
|
|
1680
|
+
* is left to the unchanged-fill.
|
|
1681
|
+
* 3. **The destination must be inside the array.** `originalIndex + offset`
|
|
1682
|
+
* out of range writes past the end (or at −1), leaves a −1 hole behind, and
|
|
1683
|
+
* the fill loop then reads `unchanged[-1]` = `undefined`. Nothing throws:
|
|
1684
|
+
* the animation simply draws a slot that is not a slot.
|
|
1685
|
+
* 4. A slot the skeleton does not have IS caught by the parser (`Draw order
|
|
1686
|
+
* slot not found`) — but in the consumer's process, which is late, so it is
|
|
1687
|
+
* refused here too.
|
|
1688
|
+
*
|
|
1689
|
+
* `A31_DRAW_ORDER_OFFSETS_RESOLVE` checks the same four properties from the
|
|
1690
|
+
* other side, on the emitted file, because a hand-written or foreign skeleton
|
|
1691
|
+
* never passed through this function.
|
|
1692
|
+
*/
|
|
1693
|
+
function compileDrawOrder(
|
|
1694
|
+
keys: MotionDrawOrderKey[],
|
|
1695
|
+
animName: string,
|
|
1696
|
+
duration: number,
|
|
1697
|
+
slots: SpineSlot[],
|
|
1698
|
+
): SpineTimelineKey[] {
|
|
1699
|
+
const where = `animation "${animName}" drawOrder`;
|
|
1700
|
+
if (!keys.length) throw new CompileError(`${where}: no keys`);
|
|
1701
|
+
const indexOf = new Map(slots.map((s, i) => [s.name, i]));
|
|
1702
|
+
|
|
1703
|
+
const out: SpineTimelineKey[] = [];
|
|
1704
|
+
for (let i = 0; i < keys.length; i++) {
|
|
1705
|
+
const key = keys[i];
|
|
1706
|
+
const time = r6(key.t);
|
|
1707
|
+
if (i > 0 && time <= (out[i - 1].time as number)) {
|
|
1708
|
+
throw new CompileError(`${where}: key times must strictly increase (at t=${key.t})`);
|
|
1709
|
+
}
|
|
1710
|
+
checkKeyTime(where, time, key.t, duration);
|
|
1711
|
+
// No offsets = "back to the setup draw order", which is the parser's own
|
|
1712
|
+
// encoding for it. An empty array means the same thing and is written the
|
|
1713
|
+
// same way, so that two spellings cannot emit two different files.
|
|
1714
|
+
if (!key.offsets?.length) {
|
|
1715
|
+
out.push({ time });
|
|
1716
|
+
continue;
|
|
1717
|
+
}
|
|
1718
|
+
const seen = new Set<string>();
|
|
1719
|
+
const entries: Array<{ slot: string; offset: number; index: number }> = [];
|
|
1720
|
+
for (const off of key.offsets) {
|
|
1721
|
+
const index = indexOf.get(off.slot);
|
|
1722
|
+
if (index === undefined) {
|
|
1723
|
+
throw new CompileError(`${where} at t=${key.t}: slot "${off.slot}" is not one this rig emits`);
|
|
1724
|
+
}
|
|
1725
|
+
if (seen.has(off.slot)) {
|
|
1726
|
+
throw new CompileError(`${where} at t=${key.t}: slot "${off.slot}" is offset twice in one key`);
|
|
1727
|
+
}
|
|
1728
|
+
if (!Number.isInteger(off.offset)) {
|
|
1729
|
+
throw new CompileError(`${where} at t=${key.t}: slot "${off.slot}" offset ${off.offset} is not a whole number`);
|
|
1730
|
+
}
|
|
1731
|
+
const landing = index + off.offset;
|
|
1732
|
+
if (landing < 0 || landing >= slots.length) {
|
|
1733
|
+
throw new CompileError(
|
|
1734
|
+
`${where} at t=${key.t}: slot "${off.slot}" is at index ${index} and offset ${off.offset} puts it at ` +
|
|
1735
|
+
`${landing}, outside the ${slots.length} emitted slots`,
|
|
1736
|
+
);
|
|
1737
|
+
}
|
|
1738
|
+
seen.add(off.slot);
|
|
1739
|
+
entries.push({ slot: off.slot, offset: off.offset, index });
|
|
1740
|
+
}
|
|
1741
|
+
entries.sort((a, b) => a.index - b.index);
|
|
1742
|
+
out.push({ time, offsets: entries.map((e) => ({ slot: e.slot, offset: e.offset })) });
|
|
1743
|
+
}
|
|
1744
|
+
return out;
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
function resolveTargets(track: MotionTrack, motion: MotionSpec, animName: string): string[] {
|
|
1748
|
+
const named = [track.slot, track.group, track.bone, track.physics].filter((v) => v !== undefined);
|
|
1749
|
+
if (named.length > 1) {
|
|
1750
|
+
throw new CompileError(`animation "${animName}": a track names more than one target (slot/group/bone/physics)`);
|
|
1751
|
+
}
|
|
1752
|
+
if (track.property in PHYSICS_TRACKS) {
|
|
1753
|
+
if (track.physics) return [track.physics];
|
|
1754
|
+
if (track.group) {
|
|
1755
|
+
const members = motion.groups?.[track.group];
|
|
1756
|
+
if (!members) throw new CompileError(`animation "${animName}": unknown group "${track.group}"`);
|
|
1757
|
+
return members;
|
|
1758
|
+
}
|
|
1759
|
+
throw new CompileError(
|
|
1760
|
+
`animation "${animName}": "${track.property}" is a physics timeline but no constraint or group is named`,
|
|
1761
|
+
);
|
|
1762
|
+
}
|
|
1763
|
+
if (track.physics) {
|
|
1764
|
+
throw new CompileError(
|
|
1765
|
+
`animation "${animName}": physics constraint "${track.physics}" cannot take property "${track.property}"`,
|
|
1766
|
+
);
|
|
1767
|
+
}
|
|
1768
|
+
const isBoneTrack = track.property in BONE_TRACKS;
|
|
1769
|
+
if (isBoneTrack && !track.bone && !track.group) {
|
|
1770
|
+
throw new CompileError(`animation "${animName}": "${track.property}" is a bone track but no bone is named`);
|
|
1771
|
+
}
|
|
1772
|
+
if (!isBoneTrack && track.bone) {
|
|
1773
|
+
throw new CompileError(`animation "${animName}": bone "${track.bone}" cannot take slot property "${track.property}"`);
|
|
1774
|
+
}
|
|
1775
|
+
if (track.bone) return [track.bone];
|
|
1776
|
+
if (track.slot) return [track.slot];
|
|
1777
|
+
if (track.group) {
|
|
1778
|
+
// A group's members are bones or slots depending on the property, which is
|
|
1779
|
+
// what lets `stagger` express the ring lag: four grips, one track, a few
|
|
1780
|
+
// frames apart. Plan 02 section 4-2 calls that lag the real detail of the
|
|
1781
|
+
// stroke, and it is the difference between a ring following the part and two
|
|
1782
|
+
// objects moving together (which reads as a composite).
|
|
1783
|
+
const members = motion.groups?.[track.group];
|
|
1784
|
+
if (!members) throw new CompileError(`animation "${animName}": unknown group "${track.group}"`);
|
|
1785
|
+
return members;
|
|
1786
|
+
}
|
|
1787
|
+
throw new CompileError(`animation "${animName}": a track targets neither slot nor group`);
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
function compileTrack(
|
|
1791
|
+
track: MotionTrack,
|
|
1792
|
+
motion: MotionSpec,
|
|
1793
|
+
animName: string,
|
|
1794
|
+
duration: number,
|
|
1795
|
+
target: string,
|
|
1796
|
+
shift: number,
|
|
1797
|
+
skinAttachments: Record<string, Record<string, SpineAttachment>>,
|
|
1798
|
+
): SpineTimelineKey[] {
|
|
1799
|
+
const where = `animation "${animName}" slot "${target}" ${track.property}`;
|
|
1800
|
+
if (!track.keys.length) throw new CompileError(`${where}: no keys`);
|
|
1801
|
+
|
|
1802
|
+
const out: SpineTimelineKey[] = [];
|
|
1803
|
+
for (let i = 0; i < track.keys.length; i++) {
|
|
1804
|
+
const key = track.keys[i];
|
|
1805
|
+
const next = track.keys[i + 1];
|
|
1806
|
+
const time = r6(key.t + shift);
|
|
1807
|
+
if (i > 0 && time <= (out[i - 1].time as number)) {
|
|
1808
|
+
throw new CompileError(`${where}: key times must strictly increase (at t=${key.t})`);
|
|
1809
|
+
}
|
|
1810
|
+
checkKeyTime(where, time, key.t, duration);
|
|
1811
|
+
|
|
1812
|
+
if (track.property === 'attachment') {
|
|
1813
|
+
if (key.v !== null && typeof key.v !== 'string') {
|
|
1814
|
+
throw new CompileError(`${where}: attachment key value must be a string or null`);
|
|
1815
|
+
}
|
|
1816
|
+
if (key.v !== null && !(key.v in (skinAttachments[target] ?? {}))) {
|
|
1817
|
+
throw new CompileError(`${where}: attachment "${key.v}" is not in slot "${target}"`);
|
|
1818
|
+
}
|
|
1819
|
+
if (key.ease) throw new CompileError(`${where}: attachment keys cannot carry an easing`);
|
|
1820
|
+
// Attachment timelines are inherently stepped — exactly what lip-sync wants.
|
|
1821
|
+
out.push({ time, name: key.v });
|
|
1822
|
+
continue;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
// rgba
|
|
1826
|
+
if (!Array.isArray(key.v)) throw new CompileError(`${where}: rgba key value must be [r,g,b,a]`);
|
|
1827
|
+
const entry: SpineTimelineKey = { time, color: rgbaHex(key.v) };
|
|
1828
|
+
if (key.ease !== undefined && key.curve !== undefined) {
|
|
1829
|
+
throw new CompileError(`${where}: a key carries both a named easing and a raw curve; pick one`);
|
|
1830
|
+
}
|
|
1831
|
+
if (key.curve !== undefined) {
|
|
1832
|
+
if (!next) throw new CompileError(`${where}: last key carries a curve but has nothing to ease to`);
|
|
1833
|
+
entry.curve = rawCurve(key.curve, 4, where, String(key.t));
|
|
1834
|
+
out.push(entry);
|
|
1835
|
+
continue;
|
|
1836
|
+
}
|
|
1837
|
+
if (key.ease && next) {
|
|
1838
|
+
if (key.ease === 'stepped') {
|
|
1839
|
+
entry.curve = 'stepped';
|
|
1840
|
+
} else {
|
|
1841
|
+
const handles = motion.easings?.[key.ease];
|
|
1842
|
+
if (!handles) throw new CompileError(`${where}: unknown easing "${key.ease}"`);
|
|
1843
|
+
if (!Array.isArray(next.v)) {
|
|
1844
|
+
throw new CompileError(`${where}: rgba key value must be [r,g,b,a]`);
|
|
1845
|
+
}
|
|
1846
|
+
const t2 = r6(next.t + shift);
|
|
1847
|
+
// 4 numbers per channel, r g b a — 16 in total. Short arrays become NaN
|
|
1848
|
+
// curves with no error.
|
|
1849
|
+
const curve: number[] = [];
|
|
1850
|
+
for (let c = 0; c < 4; c++) {
|
|
1851
|
+
curve.push(...bezierForChannel(handles, time, t2, key.v[c], next.v[c]));
|
|
1852
|
+
}
|
|
1853
|
+
entry.curve = curve;
|
|
1854
|
+
}
|
|
1855
|
+
} else if (key.ease && !next) {
|
|
1856
|
+
throw new CompileError(`${where}: last key carries an easing but has nothing to ease to`);
|
|
1857
|
+
}
|
|
1858
|
+
out.push(entry);
|
|
1859
|
+
}
|
|
1860
|
+
return out;
|
|
1861
|
+
}
|