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/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