spine-rigc 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -11
- package/cli.ts +366 -15
- package/docs/AUTHORING.md +290 -9
- package/package.json +1 -1
- package/src/ballot.ts +869 -0
- package/src/compile.ts +612 -0
- package/src/preview.ts +2 -2
- package/src/types.ts +163 -0
- package/src/validate.ts +229 -1
package/src/compile.ts
CHANGED
|
@@ -52,10 +52,13 @@ import type {
|
|
|
52
52
|
EasingHandles,
|
|
53
53
|
FaceManifest,
|
|
54
54
|
FaceManifestPart,
|
|
55
|
+
MotionDeformTrack,
|
|
55
56
|
MotionDrawOrderKey,
|
|
56
57
|
MotionEventKey,
|
|
58
|
+
MotionIkTrack,
|
|
57
59
|
MotionSpec,
|
|
58
60
|
MotionTrack,
|
|
61
|
+
MotionTransformTrack,
|
|
59
62
|
RigInfo,
|
|
60
63
|
SpineAttachment,
|
|
61
64
|
SpineBone,
|
|
@@ -206,6 +209,78 @@ const PHYSICS_TRACKS: Record<string, { fields: string[]; identity: number[] }> =
|
|
|
206
209
|
reset: { fields: [], identity: [] },
|
|
207
210
|
};
|
|
208
211
|
|
|
212
|
+
/**
|
|
213
|
+
* One numeric channel of a constraint timeline: the JSON field, the value the
|
|
214
|
+
* parser uses when a key omits it, and — for `mixY` alone — the field it takes
|
|
215
|
+
* that default FROM.
|
|
216
|
+
*
|
|
217
|
+
* Channel order is load-bearing twice over. `readCurve` indexes a curve array by
|
|
218
|
+
* channel (`curve[value << 2]`), so the order here is the order four-number
|
|
219
|
+
* groups concatenate in; and the parser reads the fields in this order, so
|
|
220
|
+
* emitting them in it keeps the file readable against an editor export.
|
|
221
|
+
*/
|
|
222
|
+
interface ConstraintChannel {
|
|
223
|
+
field: string;
|
|
224
|
+
dflt: number;
|
|
225
|
+
/** `mixY` defaults to the SAME key's `mixX`, not to 1 (`:988`). */
|
|
226
|
+
inheritsFrom?: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** A stepped-by-nature boolean on a constraint key. Never a curve channel. */
|
|
230
|
+
interface ConstraintFlag {
|
|
231
|
+
field: string;
|
|
232
|
+
dflt: boolean;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
interface ConstraintTimelineShape {
|
|
236
|
+
channels: ConstraintChannel[];
|
|
237
|
+
flags: ConstraintFlag[];
|
|
238
|
+
/** Per-field bounds, where the runtime documents one. */
|
|
239
|
+
range: Record<string, [number, number]>;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The two constraint groups that are ONE unnamed timeline per constraint
|
|
244
|
+
* (`animations.<a>.ik.<name>`, `animations.<a>.transform.<name>`).
|
|
245
|
+
*
|
|
246
|
+
* ⚠️ Every field is optional in the file and every one has a per-key default, so
|
|
247
|
+
* omitting a field on one key of a track does not carry the previous key's value
|
|
248
|
+
* forward — it snaps to the default. `compileConstraintTrack` refuses a track
|
|
249
|
+
* whose keys disagree about which fields they name, because that is the shape
|
|
250
|
+
* that loads clean and plays something nobody wrote.
|
|
251
|
+
*
|
|
252
|
+
* The bounds: `IkConstraintPose.mix` is documented as a percentage 0-1 and
|
|
253
|
+
* `softness` as a distance, while every transform mix is documented **unbounded**
|
|
254
|
+
* — so only the IK pair carries a range, and refusing a transform mix above 1
|
|
255
|
+
* would refuse correct data (an over-mix is a real editor idiom).
|
|
256
|
+
*/
|
|
257
|
+
const CONSTRAINT_TIMELINES: Record<'ik' | 'transform', ConstraintTimelineShape> = {
|
|
258
|
+
ik: {
|
|
259
|
+
channels: [
|
|
260
|
+
{ field: 'mix', dflt: 1 },
|
|
261
|
+
{ field: 'softness', dflt: 0 },
|
|
262
|
+
],
|
|
263
|
+
flags: [
|
|
264
|
+
{ field: 'bendPositive', dflt: true },
|
|
265
|
+
{ field: 'compress', dflt: false },
|
|
266
|
+
{ field: 'stretch', dflt: false },
|
|
267
|
+
],
|
|
268
|
+
range: { mix: [0, 1], softness: [0, Infinity] },
|
|
269
|
+
},
|
|
270
|
+
transform: {
|
|
271
|
+
channels: [
|
|
272
|
+
{ field: 'mixRotate', dflt: 1 },
|
|
273
|
+
{ field: 'mixX', dflt: 1 },
|
|
274
|
+
{ field: 'mixY', dflt: 1, inheritsFrom: 'mixX' },
|
|
275
|
+
{ field: 'mixScaleX', dflt: 1 },
|
|
276
|
+
{ field: 'mixScaleY', dflt: 1 },
|
|
277
|
+
{ field: 'mixShearY', dflt: 1 },
|
|
278
|
+
],
|
|
279
|
+
flags: [],
|
|
280
|
+
range: {},
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
|
|
209
284
|
/** Physics constraint fields and their parser defaults (SkeletonJson.js:295-319). */
|
|
210
285
|
const PHYSICS_COMPONENTS = ['x', 'y', 'rotate', 'scaleX', 'shearX'] as const;
|
|
211
286
|
const PHYSICS_PARAMS: Array<[string, number]> = [
|
|
@@ -767,9 +842,15 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
767
842
|
const constraints: SpineConstraint[] = [];
|
|
768
843
|
const physicsReport: CompileResult['physics'] = [];
|
|
769
844
|
const constraintNames = new Set<string>();
|
|
845
|
+
// `ik` and `transform` timelines resolve their target by name AND by type —
|
|
846
|
+
// `findConstraint(name, IkConstraintData)` returns null for a transform
|
|
847
|
+
// constraint of the same name and the parser then throws. Keeping the type
|
|
848
|
+
// beside the name is what lets the refusal say which of the two it is.
|
|
849
|
+
const constraintTypes = new Map<string, string>();
|
|
770
850
|
for (const spec of rig.constraints ?? []) {
|
|
771
851
|
constraints.push(buildRigConstraint(spec as RigConstraintInput, boneNames));
|
|
772
852
|
constraintNames.add(spec.name);
|
|
853
|
+
constraintTypes.set(spec.name, spec.type);
|
|
773
854
|
}
|
|
774
855
|
withMotionSource(() => {
|
|
775
856
|
for (const [name, spec] of Object.entries(motion.physics ?? {})) {
|
|
@@ -777,6 +858,7 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
777
858
|
throw new CompileError(`constraint "${name}" is declared in both the rig spec and the motion spec's physics table`);
|
|
778
859
|
}
|
|
779
860
|
constraintNames.add(name);
|
|
861
|
+
constraintTypes.set(name, 'physics');
|
|
780
862
|
if (!boneNames.has(spec.bone)) {
|
|
781
863
|
throw new CompileError(`physics constraint "${name}" targets unknown bone "${spec.bone}"`);
|
|
782
864
|
}
|
|
@@ -866,6 +948,103 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
866
948
|
});
|
|
867
949
|
}
|
|
868
950
|
|
|
951
|
+
// -- constraint timelines: one unnamed timeline per constraint ---------
|
|
952
|
+
//
|
|
953
|
+
// `ik` and `transform` sit beside `tracks` rather than in it because their
|
|
954
|
+
// keys carry named fields instead of one `v` — see `MotionAnimation.ik`.
|
|
955
|
+
// The target is resolved by name AND by type: `findConstraint(name,
|
|
956
|
+
// IkConstraintData)` misses a transform constraint of the same name and the
|
|
957
|
+
// parser throws in the consumer's process, so the mismatch is named here.
|
|
958
|
+
const constraintTimelines: Record<'ik' | 'transform', Record<string, SpineTimelineKey[]>> = {
|
|
959
|
+
ik: {},
|
|
960
|
+
transform: {},
|
|
961
|
+
};
|
|
962
|
+
for (const group of ['ik', 'transform'] as const) {
|
|
963
|
+
const tracks: Array<MotionIkTrack | MotionTransformTrack> = anim[group] ?? [];
|
|
964
|
+
if (!Array.isArray(tracks)) {
|
|
965
|
+
throw new CompileError(`animation "${animName}": "${group}" must be an array of { constraint, keys } entries`);
|
|
966
|
+
}
|
|
967
|
+
for (const track of tracks) {
|
|
968
|
+
const name = track.constraint;
|
|
969
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
970
|
+
throw new CompileError(`animation "${animName}": a ${group} timeline needs a "constraint" name`);
|
|
971
|
+
}
|
|
972
|
+
const type = constraintTypes.get(name);
|
|
973
|
+
if (type === undefined) {
|
|
974
|
+
const known = [...constraintTypes.entries()].filter(([, t]) => t === group).map(([n]) => n);
|
|
975
|
+
throw new CompileError(
|
|
976
|
+
`animation "${animName}" keys unknown ${group} constraint "${name}"; ` +
|
|
977
|
+
(known.length
|
|
978
|
+
? `the rig declares ${group} constraint(s): ${known.join(', ')}`
|
|
979
|
+
: `the rig declares no ${group} constraint at all`),
|
|
980
|
+
);
|
|
981
|
+
}
|
|
982
|
+
if (type !== group) {
|
|
983
|
+
throw new CompileError(
|
|
984
|
+
`animation "${animName}" keys "${name}" as ${group === 'ik' ? 'an' : 'a'} ${group} constraint, but the rig declares it as a ` +
|
|
985
|
+
`"${type}" constraint — the parser looks a timeline's target up by name AND type, misses, and throws`,
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
if (constraintTimelines[group][name]) {
|
|
989
|
+
throw new CompileError(
|
|
990
|
+
`animation "${animName}" has two ${group} timelines on constraint "${name}"; ` +
|
|
991
|
+
'the group holds one timeline per constraint, so merge them into one',
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
const keys = compileConstraintTrack(group, track, motion, animName, anim.duration);
|
|
995
|
+
for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
|
|
996
|
+
constraintTimelines[group][name] = keys;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// -- deform timelines: keyed on a skin/slot/attachment triple ----------
|
|
1001
|
+
// Four deep, because the format is: skin -> slot -> attachment -> timeline
|
|
1002
|
+
// name -> keys. `deform` is one of two timeline names an attachment can
|
|
1003
|
+
// carry (the other is `sequence`), which is why the level exists at all.
|
|
1004
|
+
const deformTimelines: Record<string, Record<string, Record<string, Record<string, SpineTimelineKey[]>>>> = {};
|
|
1005
|
+
const deformTracks: MotionDeformTrack[] = anim.deform ?? [];
|
|
1006
|
+
if (!Array.isArray(deformTracks)) {
|
|
1007
|
+
throw new CompileError(
|
|
1008
|
+
`animation "${animName}": "deform" must be an array of { slot, attachment, keys } entries`,
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
for (const track of deformTracks) {
|
|
1012
|
+
const skinName = track.skin ?? 'default';
|
|
1013
|
+
const at = `animation "${animName}" deform ${skinName}/${String(track.slot)}/${String(track.attachment)}`;
|
|
1014
|
+
const table = skinTables.get(skinName);
|
|
1015
|
+
if (!table) {
|
|
1016
|
+
throw new CompileError(
|
|
1017
|
+
`${at}: this rig emits no skin called "${skinName}" (it emits: ${[...skinTables.keys()].join(', ')})`,
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
const perSlot = table[track.slot];
|
|
1021
|
+
if (!perSlot) {
|
|
1022
|
+
throw new CompileError(
|
|
1023
|
+
`${at}: skin "${skinName}" gives slot "${String(track.slot)}" no attachments` +
|
|
1024
|
+
(slotNames.has(track.slot) ? '' : ', and this rig does not declare that slot at all'),
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
const attachment = perSlot[track.attachment];
|
|
1028
|
+
if (!attachment) {
|
|
1029
|
+
throw new CompileError(
|
|
1030
|
+
`${at}: slot "${track.slot}" in skin "${skinName}" has no attachment "${String(track.attachment)}" ` +
|
|
1031
|
+
`(it has: ${Object.keys(perSlot).join(', ')})`,
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
if (deformTimelines[skinName]?.[track.slot]?.[track.attachment]) {
|
|
1035
|
+
throw new CompileError(`${at}: two deform timelines on one attachment; merge them into one`);
|
|
1036
|
+
}
|
|
1037
|
+
const keys = compileDeformTrack(
|
|
1038
|
+
track,
|
|
1039
|
+
motion,
|
|
1040
|
+
animName,
|
|
1041
|
+
anim.duration,
|
|
1042
|
+
deformGeometryOf(attachment, at),
|
|
1043
|
+
);
|
|
1044
|
+
for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
|
|
1045
|
+
((deformTimelines[skinName] ??= {})[track.slot] ??= {})[track.attachment] = { deform: keys };
|
|
1046
|
+
}
|
|
1047
|
+
|
|
869
1048
|
const drawOrder = anim.drawOrder ? compileDrawOrder(anim.drawOrder, animName, anim.duration, slots) : null;
|
|
870
1049
|
if (drawOrder) for (const key of drawOrder) compiledDuration = Math.max(compiledDuration, key.time as number);
|
|
871
1050
|
|
|
@@ -889,10 +1068,18 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
889
1068
|
`animation "${animName}" declares duration ${anim.duration}s but its last key is at ${compiledDuration}s`,
|
|
890
1069
|
);
|
|
891
1070
|
}
|
|
1071
|
+
// Group order is `readAnimation`'s own reading order, so an emitted file
|
|
1072
|
+
// diffs cleanly against an editor export. Each line is conditional, which
|
|
1073
|
+
// is what keeps a spec that uses none of the new groups byte-identical.
|
|
892
1074
|
animations[animName] = {};
|
|
893
1075
|
if (Object.keys(slotTimelines).length) animations[animName].slots = slotTimelines;
|
|
894
1076
|
if (Object.keys(boneTimelines).length) animations[animName].bones = boneTimelines;
|
|
1077
|
+
if (Object.keys(constraintTimelines.ik).length) animations[animName].ik = constraintTimelines.ik;
|
|
1078
|
+
if (Object.keys(constraintTimelines.transform).length) {
|
|
1079
|
+
animations[animName].transform = constraintTimelines.transform;
|
|
1080
|
+
}
|
|
895
1081
|
if (Object.keys(physicsTimelines).length) animations[animName].physics = physicsTimelines;
|
|
1082
|
+
if (Object.keys(deformTimelines).length) animations[animName].attachments = deformTimelines;
|
|
896
1083
|
if (drawOrder) animations[animName].drawOrder = drawOrder;
|
|
897
1084
|
if (eventKeys) animations[animName].events = eventKeys;
|
|
898
1085
|
}
|
|
@@ -2110,6 +2297,431 @@ function compileEvents(
|
|
|
2110
2297
|
return out;
|
|
2111
2298
|
}
|
|
2112
2299
|
|
|
2300
|
+
/**
|
|
2301
|
+
* An IK or transform constraint keyed over time — `animations.<a>.<group>.<name>`.
|
|
2302
|
+
*
|
|
2303
|
+
* One function for both because the two differ only in their field table: the
|
|
2304
|
+
* group is one unnamed timeline per constraint, every field is optional with a
|
|
2305
|
+
* per-key default, and the curve concatenates four numbers per channel in field
|
|
2306
|
+
* order. `CONSTRAINT_TIMELINES` holds what differs.
|
|
2307
|
+
*
|
|
2308
|
+
* 🚨 The refusal that is not obvious is the **uniform field set**. In this format
|
|
2309
|
+
* a key does not inherit anything from the key before it: `getValue(keyMap,
|
|
2310
|
+
* "softness", 0)` is read fresh per key, so a track written as
|
|
2311
|
+
*
|
|
2312
|
+
* ```
|
|
2313
|
+
* { "t": 0, "mix": 1, "softness": 20 }, { "t": 1, "mix": 0 }
|
|
2314
|
+
* ```
|
|
2315
|
+
*
|
|
2316
|
+
* does not hold softness at 20 and fade the mix out — it snaps softness to 0 at
|
|
2317
|
+
* t=1 and interpolates from 20 down to 0 on the way, which is a thing the author
|
|
2318
|
+
* did not write and cannot see. It loads, it plays, and it is wrong. So every key
|
|
2319
|
+
* of a track has to name the same fields; stating the default explicitly is the
|
|
2320
|
+
* way to opt in.
|
|
2321
|
+
*
|
|
2322
|
+
* ⚠️ The values a curve is built between are the **effective** ones — the
|
|
2323
|
+
* author's number where there is one, the parser's default where there is not.
|
|
2324
|
+
* That is reading the format, not inventing a value: it is exactly what the
|
|
2325
|
+
* runtime will interpolate, and a bezier built against anything else would
|
|
2326
|
+
* describe a curve the player does not play.
|
|
2327
|
+
*/
|
|
2328
|
+
function compileConstraintTrack(
|
|
2329
|
+
group: 'ik' | 'transform',
|
|
2330
|
+
track: MotionIkTrack | MotionTransformTrack,
|
|
2331
|
+
motion: MotionSpec,
|
|
2332
|
+
animName: string,
|
|
2333
|
+
duration: number,
|
|
2334
|
+
): SpineTimelineKey[] {
|
|
2335
|
+
const shape = CONSTRAINT_TIMELINES[group];
|
|
2336
|
+
const article = group === 'ik' ? 'an' : 'a';
|
|
2337
|
+
const where = `animation "${animName}" ${group} constraint "${track.constraint}"`;
|
|
2338
|
+
const keys = track.keys;
|
|
2339
|
+
if (!Array.isArray(keys) || keys.length === 0) throw new CompileError(`${where}: no keys`);
|
|
2340
|
+
|
|
2341
|
+
const read = (key: MotionIkTrack['keys'][number] | MotionTransformTrack['keys'][number], field: string): unknown =>
|
|
2342
|
+
(key as unknown as Record<string, unknown>)[field];
|
|
2343
|
+
const named = (key: MotionIkTrack['keys'][number] | MotionTransformTrack['keys'][number]): string[] =>
|
|
2344
|
+
[...shape.channels, ...shape.flags].map((c) => c.field).filter((field) => read(key, field) !== undefined);
|
|
2345
|
+
|
|
2346
|
+
// The uniform-field-set rule, checked against key 0 so the message can name the
|
|
2347
|
+
// key that differs rather than "some key".
|
|
2348
|
+
const first = named(keys[0]);
|
|
2349
|
+
const firstSet = new Set(first);
|
|
2350
|
+
keys.forEach((key, i) => {
|
|
2351
|
+
if (i === 0) return;
|
|
2352
|
+
const here = named(key);
|
|
2353
|
+
for (const field of here) {
|
|
2354
|
+
if (firstSet.has(field)) continue;
|
|
2355
|
+
throw new CompileError(
|
|
2356
|
+
`${where}: key ${i} (t=${key.t}) names "${field}" and key 0 does not. Every key of ${article} ${group} timeline ` +
|
|
2357
|
+
'is read with its own default, so a field stated on some keys and not others snaps to the default on ' +
|
|
2358
|
+
'the rest — state it on every key or on none.',
|
|
2359
|
+
);
|
|
2360
|
+
}
|
|
2361
|
+
for (const field of first) {
|
|
2362
|
+
if (here.includes(field)) continue;
|
|
2363
|
+
throw new CompileError(
|
|
2364
|
+
`${where}: key 0 names "${field}" and key ${i} (t=${key.t}) does not. Every key of ${article} ${group} timeline ` +
|
|
2365
|
+
`is read with its own default, so "${field}" would snap to ` +
|
|
2366
|
+
`${JSON.stringify(defaultOf(shape, field))} at t=${key.t} — state it on every key or on none.`,
|
|
2367
|
+
);
|
|
2368
|
+
}
|
|
2369
|
+
});
|
|
2370
|
+
|
|
2371
|
+
/** The value the runtime will see for `field` on this key: authored, or default. */
|
|
2372
|
+
const effective = (key: MotionIkTrack['keys'][number] | MotionTransformTrack['keys'][number], channel: ConstraintChannel): number => {
|
|
2373
|
+
const v = read(key, channel.field);
|
|
2374
|
+
if (v !== undefined) return v as number;
|
|
2375
|
+
if (channel.inheritsFrom === undefined) return channel.dflt;
|
|
2376
|
+
const inherited = read(key, channel.inheritsFrom);
|
|
2377
|
+
return inherited === undefined ? channel.dflt : (inherited as number);
|
|
2378
|
+
};
|
|
2379
|
+
|
|
2380
|
+
const out: SpineTimelineKey[] = [];
|
|
2381
|
+
for (let i = 0; i < keys.length; i++) {
|
|
2382
|
+
const key = keys[i];
|
|
2383
|
+
const next = keys[i + 1];
|
|
2384
|
+
if (!Number.isFinite(key.t)) throw new CompileError(`${where}: key ${i} has a non-finite time ${String(key.t)}`);
|
|
2385
|
+
const time = keyTime(key.t);
|
|
2386
|
+
if (i > 0 && time <= (out[i - 1].time as number)) {
|
|
2387
|
+
throw new CompileError(`${where}: key times must strictly increase (at t=${key.t})`);
|
|
2388
|
+
}
|
|
2389
|
+
checkKeyTime(where, time, key.t, duration);
|
|
2390
|
+
|
|
2391
|
+
const entry: SpineTimelineKey = { time };
|
|
2392
|
+
for (const channel of shape.channels) {
|
|
2393
|
+
const v = read(key, channel.field);
|
|
2394
|
+
if (v === undefined) continue;
|
|
2395
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) {
|
|
2396
|
+
throw new CompileError(`${where} (t=${key.t}): ${channel.field} is ${JSON.stringify(v)}, not a finite number`);
|
|
2397
|
+
}
|
|
2398
|
+
const bounds = shape.range[channel.field];
|
|
2399
|
+
if (bounds && (v < bounds[0] || v > bounds[1])) {
|
|
2400
|
+
throw new CompileError(
|
|
2401
|
+
`${where} (t=${key.t}): ${channel.field} is ${v}, outside ${bounds[0]}..${
|
|
2402
|
+
bounds[1] === Infinity ? '∞' : bounds[1]
|
|
2403
|
+
} — the runtime documents it as ${channel.field === 'mix' ? 'a percentage 0-1' : 'a distance'}`,
|
|
2404
|
+
);
|
|
2405
|
+
}
|
|
2406
|
+
entry[channel.field] = r6(v);
|
|
2407
|
+
}
|
|
2408
|
+
for (const flag of shape.flags) {
|
|
2409
|
+
const v = read(key, flag.field);
|
|
2410
|
+
if (v === undefined) continue;
|
|
2411
|
+
if (typeof v !== 'boolean') {
|
|
2412
|
+
throw new CompileError(`${where} (t=${key.t}): ${flag.field} is ${JSON.stringify(v)}, not true or false`);
|
|
2413
|
+
}
|
|
2414
|
+
entry[flag.field] = v;
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
if (key.ease !== undefined && key.curve !== undefined) {
|
|
2418
|
+
throw new CompileError(`${where}: a key carries both a named easing and a raw curve; pick one`);
|
|
2419
|
+
}
|
|
2420
|
+
if (key.curve !== undefined) {
|
|
2421
|
+
if (!next) throw new CompileError(`${where}: last key carries a curve but has nothing to ease to`);
|
|
2422
|
+
entry.curve = rawCurve(key.curve, shape.channels.length, where, String(key.t));
|
|
2423
|
+
} else if (key.ease !== undefined && next) {
|
|
2424
|
+
if (key.ease === 'stepped') {
|
|
2425
|
+
entry.curve = 'stepped';
|
|
2426
|
+
} else {
|
|
2427
|
+
const handles = motion.easings?.[key.ease];
|
|
2428
|
+
if (!handles) throw new CompileError(`${where}: unknown easing "${key.ease}"`);
|
|
2429
|
+
const t2 = keyTime(next.t);
|
|
2430
|
+
const curve: number[] = [];
|
|
2431
|
+
for (const channel of shape.channels) {
|
|
2432
|
+
curve.push(...bezierForChannel(handles, time, t2, effective(key, channel), effective(next, channel)));
|
|
2433
|
+
}
|
|
2434
|
+
entry.curve = curve;
|
|
2435
|
+
}
|
|
2436
|
+
} else if (key.ease !== undefined && !next) {
|
|
2437
|
+
throw new CompileError(`${where}: last key carries an easing but has nothing to ease to`);
|
|
2438
|
+
}
|
|
2439
|
+
out.push(entry);
|
|
2440
|
+
}
|
|
2441
|
+
return out;
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
/** The parser default for one field of a constraint timeline, for a message. */
|
|
2445
|
+
function defaultOf(shape: ConstraintTimelineShape, field: string): number | boolean {
|
|
2446
|
+
const channel = shape.channels.find((c) => c.field === field);
|
|
2447
|
+
if (channel) return channel.dflt;
|
|
2448
|
+
return shape.flags.find((f) => f.field === field)?.dflt ?? 0;
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
/**
|
|
2452
|
+
* What a deform key is editing: the array the parser builds for one attachment.
|
|
2453
|
+
*
|
|
2454
|
+
* The two encodings are the reason this is derived rather than assumed, and they
|
|
2455
|
+
* are the same split `readVertices` makes when it decides whether a `vertices`
|
|
2456
|
+
* array is coordinates or a weight run:
|
|
2457
|
+
*
|
|
2458
|
+
* unweighted — `deformLength = vertices.length`, one `x, y` pair per vertex;
|
|
2459
|
+
* weighted — `deformLength = vertices.length / 3 * 2`, one pair per bone
|
|
2460
|
+
* INFLUENCE, because the loaded `vertices` is `x, y, weight` per
|
|
2461
|
+
* influence.
|
|
2462
|
+
*
|
|
2463
|
+
* Same shape, two meanings, and picking the wrong one writes a run that silently
|
|
2464
|
+
* lands on the wrong vertices.
|
|
2465
|
+
*/
|
|
2466
|
+
interface DeformGeometry {
|
|
2467
|
+
weighted: boolean;
|
|
2468
|
+
/** How long the array the key edits is. */
|
|
2469
|
+
deformLength: number;
|
|
2470
|
+
vertexCount: number;
|
|
2471
|
+
/** Bone influences per vertex, in vertex order. Null on an unweighted attachment. */
|
|
2472
|
+
boneCounts: number[] | null;
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
/**
|
|
2476
|
+
* Measure one emitted attachment's deform array.
|
|
2477
|
+
*
|
|
2478
|
+
* A region attachment is refused rather than measured: it has no `vertices` at
|
|
2479
|
+
* all, so `attachment.vertices.length` throws inside the parser — one of the very
|
|
2480
|
+
* few places this format fails loudly, and it fails in the consumer's process.
|
|
2481
|
+
*/
|
|
2482
|
+
function deformGeometryOf(att: SpineAttachment, where: string): DeformGeometry {
|
|
2483
|
+
const type = (att as { type?: string }).type ?? 'region';
|
|
2484
|
+
let worldVerticesLength: number;
|
|
2485
|
+
if (type === 'mesh') {
|
|
2486
|
+
worldVerticesLength = (att as SpineMeshAttachment).uvs.length;
|
|
2487
|
+
} else if (type === 'boundingbox' || type === 'clipping') {
|
|
2488
|
+
worldVerticesLength = (att as SpineBoundingBoxAttachment).vertexCount * 2;
|
|
2489
|
+
} else {
|
|
2490
|
+
throw new CompileError(
|
|
2491
|
+
`${where}: a deform timeline keys the vertices of an attachment, and this one is a "${type}" — ` +
|
|
2492
|
+
'it has no vertex array to deform. Deformable types: mesh, boundingbox, clipping.',
|
|
2493
|
+
);
|
|
2494
|
+
}
|
|
2495
|
+
const vertices = (att as SpineMeshAttachment).vertices ?? [];
|
|
2496
|
+
const weighted = vertices.length !== worldVerticesLength;
|
|
2497
|
+
if (!weighted) {
|
|
2498
|
+
return { weighted, deformLength: worldVerticesLength, vertexCount: worldVerticesLength / 2, boneCounts: null };
|
|
2499
|
+
}
|
|
2500
|
+
// Walk the weight run for the per-vertex influence counts. The run's own shape
|
|
2501
|
+
// is already assured by the attachment builders and by A33/A04; this only
|
|
2502
|
+
// counts, and a malformed run stops rather than producing a plausible number.
|
|
2503
|
+
const boneCounts: number[] = [];
|
|
2504
|
+
for (let i = 0; i < vertices.length; ) {
|
|
2505
|
+
const n = vertices[i++];
|
|
2506
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
2507
|
+
throw new CompileError(`${where}: the attachment's weighted vertex run has a bone count of ${String(n)} at index ${i - 1}`);
|
|
2508
|
+
}
|
|
2509
|
+
i += n * 4;
|
|
2510
|
+
if (i > vertices.length) {
|
|
2511
|
+
throw new CompileError(`${where}: the attachment's weighted vertex run is truncated at vertex ${boneCounts.length}`);
|
|
2512
|
+
}
|
|
2513
|
+
boneCounts.push(n);
|
|
2514
|
+
}
|
|
2515
|
+
const influences = vertices.length / 3;
|
|
2516
|
+
return { weighted, deformLength: influences * 2, vertexCount: boneCounts.length, boneCounts };
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
/**
|
|
2520
|
+
* One attachment's geometry keyed over time —
|
|
2521
|
+
* `animations.<a>.attachments.<skin>.<slot>.<attachment>.deform`.
|
|
2522
|
+
*
|
|
2523
|
+
* ⭐ Every refusal below is a silent failure of the parser's own deform branch,
|
|
2524
|
+
* and the first is the one that matters most:
|
|
2525
|
+
*
|
|
2526
|
+
* 1. **A run that does not fit.** The parser copies with
|
|
2527
|
+
* `Utils.arrayCopy(vertices, 0, deform, start, vertices.length)` into a
|
|
2528
|
+
* `Float32Array` sized from the attachment. Writing past the end of a typed
|
|
2529
|
+
* array is a **no-op in JavaScript** — no throw, no warning — so a run one
|
|
2530
|
+
* pair too long, or aimed at the wrong attachment, loses its tail and
|
|
2531
|
+
* deforms part of the mesh correctly. That is the worst possible failure
|
|
2532
|
+
* shape: it looks almost right.
|
|
2533
|
+
* 2. **An odd `offset`, or an odd run length.** The array is `x, y` pairs; an
|
|
2534
|
+
* odd index puts every x of the run on a y and vice versa. It loads.
|
|
2535
|
+
* 3. **`fromVertex` where a vertex is not one pair.** See below.
|
|
2536
|
+
* 4. **A key that carries both a run and no room for one**, or a non-finite
|
|
2537
|
+
* offset — a NaN in the deform array propagates into world vertices.
|
|
2538
|
+
*
|
|
2539
|
+
* `fromVertex` is rigc's own field and the reason it exists is issue #89's
|
|
2540
|
+
* observation: a deform key is the only key in the format whose meaning depends
|
|
2541
|
+
* on the attachment it is attached to, and an author reasons in vertices while
|
|
2542
|
+
* the array is indexed in influences. On an **unweighted** attachment the two
|
|
2543
|
+
* coincide, so the translation is exact. On a **weighted** one it is exact only
|
|
2544
|
+
* where each vertex the run covers has exactly ONE bone on it; with two bones a
|
|
2545
|
+
* vertex occupies two pairs and "move vertex 3 by (dx, dy)" is not a statement
|
|
2546
|
+
* the array can hold — the world offset would be
|
|
2547
|
+
* `Σ weightᵦ · Mᵦ · (dx, dy)`, which equals `(dx, dy)` only if every influencing
|
|
2548
|
+
* bone happens to share one world matrix. So that case is refused by name and
|
|
2549
|
+
* `offset` stays available for an author who really is writing bind-space
|
|
2550
|
+
* offsets per influence.
|
|
2551
|
+
*/
|
|
2552
|
+
function compileDeformTrack(
|
|
2553
|
+
track: MotionDeformTrack,
|
|
2554
|
+
motion: MotionSpec,
|
|
2555
|
+
animName: string,
|
|
2556
|
+
duration: number,
|
|
2557
|
+
geometry: DeformGeometry,
|
|
2558
|
+
): SpineTimelineKey[] {
|
|
2559
|
+
const skin = track.skin ?? 'default';
|
|
2560
|
+
const where = `animation "${animName}" deform ${skin}/${track.slot}/${track.attachment}`;
|
|
2561
|
+
const keys = track.keys;
|
|
2562
|
+
if (!Array.isArray(keys) || keys.length === 0) throw new CompileError(`${where}: no keys`);
|
|
2563
|
+
|
|
2564
|
+
const out: SpineTimelineKey[] = [];
|
|
2565
|
+
for (let i = 0; i < keys.length; i++) {
|
|
2566
|
+
const key = keys[i];
|
|
2567
|
+
if (!Number.isFinite(key.t)) throw new CompileError(`${where}: key ${i} has a non-finite time ${String(key.t)}`);
|
|
2568
|
+
const time = keyTime(key.t);
|
|
2569
|
+
if (i > 0 && time <= (out[i - 1].time as number)) {
|
|
2570
|
+
throw new CompileError(`${where}: key times must strictly increase (at t=${key.t})`);
|
|
2571
|
+
}
|
|
2572
|
+
checkKeyTime(where, time, key.t, duration);
|
|
2573
|
+
if (key.offset !== undefined && key.fromVertex !== undefined) {
|
|
2574
|
+
throw new CompileError(
|
|
2575
|
+
`${where} (t=${key.t}): a key gives its start as "offset" (an index into the deform array) or as ` +
|
|
2576
|
+
'"fromVertex" (a vertex index rigc translates), never both',
|
|
2577
|
+
);
|
|
2578
|
+
}
|
|
2579
|
+
const run = key.vertices ?? null;
|
|
2580
|
+
if (run === null) {
|
|
2581
|
+
// The parser's own encoding for "no edit": with no `vertices` the deform is
|
|
2582
|
+
// the setup pose. `offset`/`fromVertex` would be pointing into nothing, and
|
|
2583
|
+
// two spellings of one key must not emit two different files.
|
|
2584
|
+
if (key.offset !== undefined || key.fromVertex !== undefined) {
|
|
2585
|
+
throw new CompileError(
|
|
2586
|
+
`${where} (t=${key.t}): the key has no "vertices", which is the format's way of saying "back to the ` +
|
|
2587
|
+
'setup pose" — so there is nothing for a start index to point at. Drop the offset, or give it a run.',
|
|
2588
|
+
);
|
|
2589
|
+
}
|
|
2590
|
+
out.push(deformKeyCurve({ time }, key, keys, i, motion, where));
|
|
2591
|
+
continue;
|
|
2592
|
+
}
|
|
2593
|
+
if (!Array.isArray(run) || run.length === 0) {
|
|
2594
|
+
throw new CompileError(
|
|
2595
|
+
`${where} (t=${key.t}): "vertices" is ${JSON.stringify(key.vertices)}; give an array of x, y offsets, ` +
|
|
2596
|
+
'or omit it entirely for "back to the setup pose"',
|
|
2597
|
+
);
|
|
2598
|
+
}
|
|
2599
|
+
if (run.length % 2 !== 0) {
|
|
2600
|
+
throw new CompileError(
|
|
2601
|
+
`${where} (t=${key.t}): "vertices" holds ${run.length} numbers; the deform array is x, y PAIRS, so a run has an even length`,
|
|
2602
|
+
);
|
|
2603
|
+
}
|
|
2604
|
+
for (const n of run) {
|
|
2605
|
+
if (typeof n !== 'number' || !Number.isFinite(n)) {
|
|
2606
|
+
throw new CompileError(`${where} (t=${key.t}): "vertices" holds a non-finite value ${JSON.stringify(n)}`);
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2609
|
+
const start = deformStart(key, run.length, geometry, where);
|
|
2610
|
+
if (start + run.length > geometry.deformLength) {
|
|
2611
|
+
throw new CompileError(
|
|
2612
|
+
`${where} (t=${key.t}): the run starts at deform index ${start} and is ${run.length} long, which ends at ` +
|
|
2613
|
+
`${start + run.length}; this attachment's deform array is ${geometry.deformLength} long ` +
|
|
2614
|
+
`(${geometry.weighted ? `${geometry.deformLength / 2} bone influences` : `${geometry.vertexCount} vertices`}). ` +
|
|
2615
|
+
'The parser copies into a Float32Array, so everything past the end is dropped without a word.',
|
|
2616
|
+
);
|
|
2617
|
+
}
|
|
2618
|
+
const entry: SpineTimelineKey = { time };
|
|
2619
|
+
// `offset` defaults to 0 in the parser and the editor omits it there, so an
|
|
2620
|
+
// authored 0, an authored `fromVertex: 0` and an absent start all emit the
|
|
2621
|
+
// same bytes — one meaning, one file.
|
|
2622
|
+
if (start !== 0) entry.offset = start;
|
|
2623
|
+
entry.vertices = run.map(r6);
|
|
2624
|
+
out.push(deformKeyCurve(entry, key, keys, i, motion, where));
|
|
2625
|
+
}
|
|
2626
|
+
return out;
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
/**
|
|
2630
|
+
* Where in the deform array this key's run begins.
|
|
2631
|
+
*
|
|
2632
|
+
* `offset` is that index outright. `fromVertex` is a vertex index, and turning
|
|
2633
|
+
* one into the other is exact only where a vertex occupies exactly one pair —
|
|
2634
|
+
* which is every vertex of an unweighted attachment and only the single-bone
|
|
2635
|
+
* vertices of a weighted one.
|
|
2636
|
+
*/
|
|
2637
|
+
function deformStart(
|
|
2638
|
+
key: MotionDeformTrack['keys'][number],
|
|
2639
|
+
runLength: number,
|
|
2640
|
+
geometry: DeformGeometry,
|
|
2641
|
+
where: string,
|
|
2642
|
+
): number {
|
|
2643
|
+
if (key.offset !== undefined) {
|
|
2644
|
+
if (!Number.isInteger(key.offset) || key.offset < 0) {
|
|
2645
|
+
throw new CompileError(
|
|
2646
|
+
`${where} (t=${key.t}): offset is ${JSON.stringify(key.offset)}; it is an index into the deform array, so a whole number ≥ 0`,
|
|
2647
|
+
);
|
|
2648
|
+
}
|
|
2649
|
+
if (key.offset % 2 !== 0) {
|
|
2650
|
+
throw new CompileError(
|
|
2651
|
+
`${where} (t=${key.t}): offset ${key.offset} is odd. The deform array is x, y pairs, so an odd start puts ` +
|
|
2652
|
+
"every x of this run on a y — it loads, and the mesh tears. Use an even index, or say which vertex you meant with \"fromVertex\".",
|
|
2653
|
+
);
|
|
2654
|
+
}
|
|
2655
|
+
return key.offset;
|
|
2656
|
+
}
|
|
2657
|
+
if (key.fromVertex === undefined) return 0;
|
|
2658
|
+
const from = key.fromVertex;
|
|
2659
|
+
if (!Number.isInteger(from) || from < 0) {
|
|
2660
|
+
throw new CompileError(`${where} (t=${key.t}): fromVertex is ${JSON.stringify(from)}; it is a vertex index, so a whole number ≥ 0`);
|
|
2661
|
+
}
|
|
2662
|
+
const covered = runLength / 2;
|
|
2663
|
+
if (from + covered > geometry.vertexCount) {
|
|
2664
|
+
throw new CompileError(
|
|
2665
|
+
`${where} (t=${key.t}): fromVertex ${from} plus ${covered} vertex offset(s) runs to vertex ${from + covered}, ` +
|
|
2666
|
+
`and the attachment has ${geometry.vertexCount}`,
|
|
2667
|
+
);
|
|
2668
|
+
}
|
|
2669
|
+
if (!geometry.weighted) return from * 2;
|
|
2670
|
+
const counts = geometry.boneCounts!;
|
|
2671
|
+
for (let v = from; v < from + covered; v++) {
|
|
2672
|
+
if (counts[v] === 1) continue;
|
|
2673
|
+
throw new CompileError(
|
|
2674
|
+
`${where} (t=${key.t}): "fromVertex" counts VERTICES, and this attachment is weighted — its deform array ` +
|
|
2675
|
+
`holds one x, y pair per bone INFLUENCE, and vertex ${v} has ${counts[v]} of them. One offset per vertex ` +
|
|
2676
|
+
'is not a thing that array can hold: the world offset of a multi-bone vertex is the weighted sum of a ' +
|
|
2677
|
+
'per-bone offset in each bone\'s own bind space, so rigc will not guess one for you. Either key the ' +
|
|
2678
|
+
'control bone instead, or write the bind-space pairs yourself and start the run with "offset" ' +
|
|
2679
|
+
`(vertex ${from} starts at deform index ${2 * counts.slice(0, from).reduce((a, b) => a + b, 0)}).`,
|
|
2680
|
+
);
|
|
2681
|
+
}
|
|
2682
|
+
let start = 0;
|
|
2683
|
+
for (let v = 0; v < from; v++) start += counts[v];
|
|
2684
|
+
return start * 2;
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
/**
|
|
2688
|
+
* A deform key's curve.
|
|
2689
|
+
*
|
|
2690
|
+
* One channel, and it is not any value in `vertices`: `readCurve(curve, timeline,
|
|
2691
|
+
* bezier, frame, 0, time, time2, 0, 1, 1)` builds the cubic between **0 and 1**,
|
|
2692
|
+
* the fraction of the way from this key's geometry to the next one's. So a named
|
|
2693
|
+
* easing here is the same shape it would be anywhere, applied to the blend rather
|
|
2694
|
+
* than to a coordinate, and a raw curve is four numbers whose value axis is 0..1.
|
|
2695
|
+
*/
|
|
2696
|
+
function deformKeyCurve(
|
|
2697
|
+
entry: SpineTimelineKey,
|
|
2698
|
+
key: MotionDeformTrack['keys'][number],
|
|
2699
|
+
keys: MotionDeformTrack['keys'],
|
|
2700
|
+
index: number,
|
|
2701
|
+
motion: MotionSpec,
|
|
2702
|
+
where: string,
|
|
2703
|
+
): SpineTimelineKey {
|
|
2704
|
+
const hasNext = index + 1 < keys.length;
|
|
2705
|
+
if (key.ease !== undefined && key.curve !== undefined) {
|
|
2706
|
+
throw new CompileError(`${where}: a key carries both a named easing and a raw curve; pick one`);
|
|
2707
|
+
}
|
|
2708
|
+
if (key.curve !== undefined) {
|
|
2709
|
+
if (!hasNext) throw new CompileError(`${where}: last key carries a curve but has nothing to ease to`);
|
|
2710
|
+
entry.curve = rawCurve(key.curve, 1, where, String(key.t));
|
|
2711
|
+
return entry;
|
|
2712
|
+
}
|
|
2713
|
+
if (key.ease === undefined) return entry;
|
|
2714
|
+
if (!hasNext) throw new CompileError(`${where}: last key carries an easing but has nothing to ease to`);
|
|
2715
|
+
if (key.ease === 'stepped') {
|
|
2716
|
+
entry.curve = 'stepped';
|
|
2717
|
+
return entry;
|
|
2718
|
+
}
|
|
2719
|
+
const handles = motion.easings?.[key.ease];
|
|
2720
|
+
if (!handles) throw new CompileError(`${where}: unknown easing "${key.ease}"`);
|
|
2721
|
+
entry.curve = bezierForChannel(handles, entry.time as number, keyTime(keys[index + 1].t), 0, 1);
|
|
2722
|
+
return entry;
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2113
2725
|
function resolveTargets(track: MotionTrack, motion: MotionSpec, animName: string): string[] {
|
|
2114
2726
|
const named = [track.slot, track.group, track.bone, track.physics].filter((v) => v !== undefined);
|
|
2115
2727
|
if (named.length > 1) {
|
package/src/preview.ts
CHANGED
|
@@ -102,7 +102,7 @@ export function dataUri(mime: string, body: string | Uint8Array): string {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
/** `#rrggbb` for the player's background, so a preview and `rigc render` agree. */
|
|
105
|
-
function backgroundHex(): string {
|
|
105
|
+
export function backgroundHex(): string {
|
|
106
106
|
return `#${BACKGROUND.slice(0, 3)
|
|
107
107
|
.map((c) => c.toString(16).padStart(2, '0'))
|
|
108
108
|
.join('')}`;
|
|
@@ -126,7 +126,7 @@ export function escapeHtml(text: string): string {
|
|
|
126
126
|
* string to a JSON reader, so nothing about the value changes. Page names come
|
|
127
127
|
* out of a file somebody else wrote, which is exactly why this is not optional.
|
|
128
128
|
*/
|
|
129
|
-
function embeddedJson(value: unknown): string {
|
|
129
|
+
export function embeddedJson(value: unknown): string {
|
|
130
130
|
return JSON.stringify(value).replace(/</g, '\\u003c');
|
|
131
131
|
}
|
|
132
132
|
|