spine-rigc 0.5.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/NOTICE.md +21 -0
- package/README.md +409 -229
- package/bin/rigc.cjs +40 -0
- package/cli.ts +916 -56
- package/docs/AUTHORING.md +345 -27
- package/docs/PROMPTING.md +106 -0
- package/docs/SPEC_COVERAGE.md +16 -14
- package/package.json +4 -2
- package/src/ballot.ts +869 -0
- package/src/compile.ts +796 -137
- package/src/emit.ts +88 -0
- package/src/json-position.ts +253 -0
- package/src/png.ts +72 -7
- package/src/preview.ts +243 -0
- package/src/render.ts +58 -0
- package/src/types.ts +171 -0
- package/src/validate.ts +287 -14
- package/tools/plate.ts +164 -21
package/src/compile.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { basename, dirname, relative, resolve } from 'node:path';
|
|
|
21
21
|
import { existsSync, readFileSync } from 'node:fs';
|
|
22
22
|
import { readPngInfo } from './png.ts';
|
|
23
23
|
import { CompileError, NotImplementedError } from './errors.ts';
|
|
24
|
+
import { parseJsonWithPosition } from './json-position.ts';
|
|
24
25
|
import {
|
|
25
26
|
parseRigSpec,
|
|
26
27
|
type RigAttachment,
|
|
@@ -51,10 +52,13 @@ import type {
|
|
|
51
52
|
EasingHandles,
|
|
52
53
|
FaceManifest,
|
|
53
54
|
FaceManifestPart,
|
|
55
|
+
MotionDeformTrack,
|
|
54
56
|
MotionDrawOrderKey,
|
|
55
57
|
MotionEventKey,
|
|
58
|
+
MotionIkTrack,
|
|
56
59
|
MotionSpec,
|
|
57
60
|
MotionTrack,
|
|
61
|
+
MotionTransformTrack,
|
|
58
62
|
RigInfo,
|
|
59
63
|
SpineAttachment,
|
|
60
64
|
SpineBone,
|
|
@@ -205,6 +209,78 @@ const PHYSICS_TRACKS: Record<string, { fields: string[]; identity: number[] }> =
|
|
|
205
209
|
reset: { fields: [], identity: [] },
|
|
206
210
|
};
|
|
207
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
|
+
|
|
208
284
|
/** Physics constraint fields and their parser defaults (SkeletonJson.js:295-319). */
|
|
209
285
|
const PHYSICS_COMPONENTS = ['x', 'y', 'rotate', 'scaleX', 'shearX'] as const;
|
|
210
286
|
const PHYSICS_PARAMS: Array<[string, number]> = [
|
|
@@ -255,8 +331,17 @@ export function bezierForChannel(
|
|
|
255
331
|
// ---------------------------------------------------------------------------
|
|
256
332
|
|
|
257
333
|
function readJson<T>(path: string): T {
|
|
334
|
+
let text: string;
|
|
258
335
|
try {
|
|
259
|
-
|
|
336
|
+
text = readFileSync(path, 'utf8');
|
|
337
|
+
} catch (err) {
|
|
338
|
+
throw new CompileError(`cannot read ${path}: ${(err as Error).message}`);
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
// A parse failure gets a line/column appended to the runtime's own
|
|
342
|
+
// message — see `parseJsonWithPosition` for why `JSON.parse` alone
|
|
343
|
+
// cannot say where.
|
|
344
|
+
return parseJsonWithPosition(text) as T;
|
|
260
345
|
} catch (err) {
|
|
261
346
|
throw new CompileError(`cannot read ${path}: ${(err as Error).message}`);
|
|
262
347
|
}
|
|
@@ -319,6 +404,36 @@ export interface CompileOptions {
|
|
|
319
404
|
imagesDir?: string;
|
|
320
405
|
}
|
|
321
406
|
|
|
407
|
+
/**
|
|
408
|
+
* One part = one page. No packer, so no PMA trap, no rotation, no strip
|
|
409
|
+
* offsets. Region covers the page exactly => u2=v2=1.
|
|
410
|
+
*
|
|
411
|
+
* Two text-shape traps are load-bearing here:
|
|
412
|
+
* * a region name is the RAW line, not a trimmed one -> no indentation;
|
|
413
|
+
* * a blank line closes the page block -> none between header and regions.
|
|
414
|
+
*
|
|
415
|
+
* Exported (rather than inlined into `compile`) so `--copy-images` can call it a
|
|
416
|
+
* second time with `page` rewritten to the copies' filenames, after the copy
|
|
417
|
+
* itself has happened — see [`src/emit.ts`](emit.ts). Everything else about an
|
|
418
|
+
* image (`region`, `width`, `height`) is unchanged by that rewrite; only where the
|
|
419
|
+
* bytes live moved.
|
|
420
|
+
*/
|
|
421
|
+
export function buildAtlasText(images: CompiledImage[]): string {
|
|
422
|
+
const atlasLines: string[] = [];
|
|
423
|
+
images.forEach((img, i) => {
|
|
424
|
+
if (i > 0) atlasLines.push(''); // exactly one blank line BETWEEN pages
|
|
425
|
+
atlasLines.push(img.page);
|
|
426
|
+
atlasLines.push(`size: ${img.width}, ${img.height}`);
|
|
427
|
+
atlasLines.push('filter: Linear, Linear');
|
|
428
|
+
atlasLines.push('pma: false');
|
|
429
|
+
atlasLines.push(img.region);
|
|
430
|
+
atlasLines.push(`bounds: 0, 0, ${img.width}, ${img.height}`);
|
|
431
|
+
atlasLines.push(`offsets: 0, 0, ${img.width}, ${img.height}`);
|
|
432
|
+
atlasLines.push('rotate: 0');
|
|
433
|
+
});
|
|
434
|
+
return `${atlasLines.join('\n')}\n`;
|
|
435
|
+
}
|
|
436
|
+
|
|
322
437
|
export function compile(opts: CompileOptions): CompileResult {
|
|
323
438
|
const rigPath = resolve(opts.rigPath);
|
|
324
439
|
const motionPath = resolve(opts.motionPath);
|
|
@@ -330,17 +445,38 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
330
445
|
const motion = readJson<MotionSpec>(motionPath);
|
|
331
446
|
const manifest = manifestPath === null ? null : readJson<FaceManifest>(manifestPath);
|
|
332
447
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
448
|
+
// The rig spec names its own path in every message `parseRigSpec` throws (its
|
|
449
|
+
// `where` argument, above). The motion spec gets no such treatment below —
|
|
450
|
+
// every "animation … bone … property" refusal is built from data alone — so a
|
|
451
|
+
// reader with two input files and one error has no way to tell which one is at
|
|
452
|
+
// fault. This wraps the motion-only regions (the version/archetype check right
|
|
453
|
+
// below, the physics table and the animation loop further down) and prefixes
|
|
454
|
+
// the motion path onto any `CompileError` that escapes them, unless the
|
|
455
|
+
// message already names it.
|
|
456
|
+
const withMotionSource = <T>(fn: () => T): T => {
|
|
457
|
+
try {
|
|
458
|
+
return fn();
|
|
459
|
+
} catch (err) {
|
|
460
|
+
if (err instanceof CompileError && !err.message.includes(motionPath)) {
|
|
461
|
+
throw new CompileError(`${motionPath}: ${err.message}`);
|
|
462
|
+
}
|
|
463
|
+
throw err;
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
withMotionSource(() => {
|
|
468
|
+
if (motion.spec !== 'rigc-motion/1') {
|
|
469
|
+
throw new CompileError(`unknown motion spec version: ${String(motion.spec)}`);
|
|
470
|
+
}
|
|
471
|
+
// The motion spec was authored against one formation. Pairing it with another
|
|
472
|
+
// rig would aim its keys at bones whose names happen to match and whose meaning
|
|
473
|
+
// does not — the class of wrongness that loads, plays and lies.
|
|
474
|
+
if (motion.archetype !== rig.name) {
|
|
475
|
+
throw new CompileError(
|
|
476
|
+
`motion spec names archetype "${motion.archetype}" but the rig spec at ${rigPath} is called "${rig.name}"`,
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
});
|
|
344
480
|
|
|
345
481
|
// The stage. The rig may state it outright (a foreign skeleton has no crop);
|
|
346
482
|
// otherwise the manifest's crop is it. With neither there is nothing to
|
|
@@ -546,25 +682,7 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
546
682
|
}
|
|
547
683
|
|
|
548
684
|
// -- 2. atlas --------------------------------------------------------------
|
|
549
|
-
|
|
550
|
-
// offsets. Region covers the page exactly => u2=v2=1.
|
|
551
|
-
//
|
|
552
|
-
// Two text-shape traps are load-bearing here:
|
|
553
|
-
// * a region name is the RAW line, not a trimmed one -> no indentation;
|
|
554
|
-
// * a blank line closes the page block -> none between header and regions.
|
|
555
|
-
const atlasLines: string[] = [];
|
|
556
|
-
images.forEach((img, i) => {
|
|
557
|
-
if (i > 0) atlasLines.push(''); // exactly one blank line BETWEEN pages
|
|
558
|
-
atlasLines.push(img.page);
|
|
559
|
-
atlasLines.push(`size: ${img.width}, ${img.height}`);
|
|
560
|
-
atlasLines.push('filter: Linear, Linear');
|
|
561
|
-
atlasLines.push('pma: false');
|
|
562
|
-
atlasLines.push(img.region);
|
|
563
|
-
atlasLines.push(`bounds: 0, 0, ${img.width}, ${img.height}`);
|
|
564
|
-
atlasLines.push(`offsets: 0, 0, ${img.width}, ${img.height}`);
|
|
565
|
-
atlasLines.push('rotate: 0');
|
|
566
|
-
});
|
|
567
|
-
const atlasText = `${atlasLines.join('\n')}\n`;
|
|
685
|
+
const atlasText = buildAtlasText(images);
|
|
568
686
|
|
|
569
687
|
// -- 3. bones --------------------------------------------------------------
|
|
570
688
|
//
|
|
@@ -724,132 +842,248 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
724
842
|
const constraints: SpineConstraint[] = [];
|
|
725
843
|
const physicsReport: CompileResult['physics'] = [];
|
|
726
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>();
|
|
727
850
|
for (const spec of rig.constraints ?? []) {
|
|
728
851
|
constraints.push(buildRigConstraint(spec as RigConstraintInput, boneNames));
|
|
729
852
|
constraintNames.add(spec.name);
|
|
853
|
+
constraintTypes.set(spec.name, spec.type);
|
|
730
854
|
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
const
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
855
|
+
withMotionSource(() => {
|
|
856
|
+
for (const [name, spec] of Object.entries(motion.physics ?? {})) {
|
|
857
|
+
if (constraintNames.has(name)) {
|
|
858
|
+
throw new CompileError(`constraint "${name}" is declared in both the rig spec and the motion spec's physics table`);
|
|
859
|
+
}
|
|
860
|
+
constraintNames.add(name);
|
|
861
|
+
constraintTypes.set(name, 'physics');
|
|
862
|
+
if (!boneNames.has(spec.bone)) {
|
|
863
|
+
throw new CompileError(`physics constraint "${name}" targets unknown bone "${spec.bone}"`);
|
|
864
|
+
}
|
|
865
|
+
const entry: SpineConstraint = {
|
|
866
|
+
name,
|
|
867
|
+
type: 'physics',
|
|
868
|
+
bone: spec.bone,
|
|
869
|
+
};
|
|
870
|
+
const components: string[] = [];
|
|
871
|
+
for (const comp of PHYSICS_COMPONENTS) {
|
|
872
|
+
const v = spec[comp];
|
|
873
|
+
if (v === undefined || v === 0) continue;
|
|
874
|
+
entry[comp] = r6(v);
|
|
875
|
+
components.push(comp);
|
|
876
|
+
}
|
|
877
|
+
if (!components.length) {
|
|
878
|
+
// The parser is happy with this and the constraint does nothing at all.
|
|
879
|
+
// A23 catches it too; refusing here means it never reaches the gate.
|
|
880
|
+
throw new CompileError(
|
|
881
|
+
`physics constraint "${name}" drives no component — set at least one of ${PHYSICS_COMPONENTS.join('/')}`,
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
for (const [param, dflt] of PHYSICS_PARAMS) {
|
|
885
|
+
const v = spec[param as keyof typeof spec] as number | undefined;
|
|
886
|
+
if (v === undefined || v === dflt) continue;
|
|
887
|
+
entry[param] = r6(v);
|
|
888
|
+
}
|
|
889
|
+
constraints.push(entry);
|
|
890
|
+
physicsReport.push({
|
|
891
|
+
name,
|
|
892
|
+
bone: spec.bone,
|
|
893
|
+
components,
|
|
894
|
+
mix: spec.mix ?? 1,
|
|
895
|
+
drivesMesh: meshBones.has(spec.bone),
|
|
896
|
+
});
|
|
757
897
|
}
|
|
758
|
-
|
|
759
|
-
const v = spec[param as keyof typeof spec] as number | undefined;
|
|
760
|
-
if (v === undefined || v === dflt) continue;
|
|
761
|
-
entry[param] = r6(v);
|
|
762
|
-
}
|
|
763
|
-
constraints.push(entry);
|
|
764
|
-
physicsReport.push({
|
|
765
|
-
name,
|
|
766
|
-
bone: spec.bone,
|
|
767
|
-
components,
|
|
768
|
-
mix: spec.mix ?? 1,
|
|
769
|
-
drivesMesh: meshBones.has(spec.bone),
|
|
770
|
-
});
|
|
771
|
-
}
|
|
898
|
+
});
|
|
772
899
|
|
|
773
900
|
// -- 5. animations ---------------------------------------------------------
|
|
774
901
|
const animations: SpineSkeletonJson['animations'] = {};
|
|
775
902
|
const declaredDurations: Record<string, number> = {};
|
|
776
903
|
const slotNames = new Set(slots.map((s) => s.name));
|
|
777
904
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
const
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
if (
|
|
793
|
-
|
|
905
|
+
withMotionSource(() => {
|
|
906
|
+
for (const [animName, anim] of Object.entries(motion.animations)) {
|
|
907
|
+
declaredDurations[animName] = anim.duration;
|
|
908
|
+
const slotTimelines: Record<string, Record<string, SpineTimelineKey[]>> = {};
|
|
909
|
+
const boneTimelines: Record<string, Record<string, SpineTimelineKey[]>> = {};
|
|
910
|
+
const physicsTimelines: Record<string, Record<string, SpineTimelineKey[]>> = {};
|
|
911
|
+
const claimed = new Set<string>();
|
|
912
|
+
let compiledDuration = 0;
|
|
913
|
+
|
|
914
|
+
for (const track of anim.tracks) {
|
|
915
|
+
const isPhysicsTrack = track.property in PHYSICS_TRACKS;
|
|
916
|
+
const isBoneTrack = !isPhysicsTrack && track.property in BONE_TRACKS;
|
|
917
|
+
const targets = resolveTargets(track, motion, animName);
|
|
918
|
+
targets.forEach((target, index) => {
|
|
919
|
+
if (isPhysicsTrack) {
|
|
920
|
+
if (!constraintNames.has(target)) {
|
|
921
|
+
throw new CompileError(`animation "${animName}" keys unknown physics constraint "${target}"`);
|
|
922
|
+
}
|
|
923
|
+
} else if (isBoneTrack) {
|
|
924
|
+
if (!boneNames.has(target)) {
|
|
925
|
+
throw new CompileError(`animation "${animName}" keys unknown bone "${target}"`);
|
|
926
|
+
}
|
|
927
|
+
} else if (!slotNames.has(target)) {
|
|
928
|
+
throw new CompileError(`animation "${animName}" targets unknown slot "${target}"`);
|
|
929
|
+
}
|
|
930
|
+
const claim = `${target}.${track.property}`;
|
|
931
|
+
if (claimed.has(claim)) {
|
|
932
|
+
throw new CompileError(
|
|
933
|
+
`animation "${animName}" has two tracks on ${claim}; merge them into one track`,
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
claimed.add(claim);
|
|
937
|
+
|
|
938
|
+
const shift = (track.lag ?? 0) + (track.stagger ?? 0) * index;
|
|
939
|
+
const keys = isPhysicsTrack
|
|
940
|
+
? compileValueTrack(track, motion, animName, anim.duration, target, shift, PHYSICS_TRACKS, 'physics constraint')
|
|
941
|
+
: isBoneTrack
|
|
942
|
+
? compileValueTrack(track, motion, animName, anim.duration, target, shift, BONE_TRACKS, 'bone')
|
|
943
|
+
: compileTrack(track, motion, animName, anim.duration, target, shift, tableFor('default'));
|
|
944
|
+
for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
|
|
945
|
+
if (isPhysicsTrack) (physicsTimelines[target] ??= {})[track.property] = keys;
|
|
946
|
+
else if (isBoneTrack) (boneTimelines[target] ??= {})[track.property] = keys;
|
|
947
|
+
else (slotTimelines[target] ??= {})[track.property] = keys;
|
|
948
|
+
});
|
|
949
|
+
}
|
|
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`);
|
|
794
971
|
}
|
|
795
|
-
|
|
796
|
-
if (
|
|
797
|
-
|
|
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
|
+
);
|
|
798
981
|
}
|
|
799
|
-
|
|
800
|
-
|
|
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;
|
|
801
997
|
}
|
|
802
|
-
|
|
803
|
-
|
|
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) {
|
|
804
1016
|
throw new CompileError(
|
|
805
|
-
|
|
1017
|
+
`${at}: this rig emits no skin called "${skinName}" (it emits: ${[...skinTables.keys()].join(', ')})`,
|
|
806
1018
|
);
|
|
807
1019
|
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
+
);
|
|
816
1044
|
for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
else (slotTimelines[target] ??= {})[track.property] = keys;
|
|
820
|
-
});
|
|
821
|
-
}
|
|
1045
|
+
((deformTimelines[skinName] ??= {})[track.slot] ??= {})[track.attachment] = { deform: keys };
|
|
1046
|
+
}
|
|
822
1047
|
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
1048
|
+
const drawOrder = anim.drawOrder ? compileDrawOrder(anim.drawOrder, animName, anim.duration, slots) : null;
|
|
1049
|
+
if (drawOrder) for (const key of drawOrder) compiledDuration = Math.max(compiledDuration, key.time as number);
|
|
1050
|
+
|
|
1051
|
+
const eventKeys = anim.events ? compileEvents(anim.events, animName, anim.duration, rig.events ?? {}) : null;
|
|
1052
|
+
// An event timeline counts towards the animation's length the same as any
|
|
1053
|
+
// other: `readAnimation` takes the duration from the longest timeline it
|
|
1054
|
+
// built, and `EventTimeline.getDuration()` is its last frame like the rest.
|
|
1055
|
+
if (eventKeys) for (const key of eventKeys) compiledDuration = Math.max(compiledDuration, key.time as number);
|
|
1056
|
+
|
|
1057
|
+
// Rule 4: the declared duration is verified, because skeleton JSON does not
|
|
1058
|
+
// carry one — the loader takes the max key time.
|
|
1059
|
+
//
|
|
1060
|
+
// This arm is about the DECLARED DURATION being wrong, so it compares one
|
|
1061
|
+
// number per animation and tolerates a frame of it. The other arm —
|
|
1062
|
+
// `checkKeyTime`, above, per key — is about a key landing past the end, and
|
|
1063
|
+
// a frame is 16,667 times too coarse to see one. Both are needed: this one
|
|
1064
|
+
// catches an animation that stops a second early, and only that one catches
|
|
1065
|
+
// a key on a track whose neighbour already sits on the declared duration.
|
|
1066
|
+
if (Math.abs(compiledDuration - anim.duration) > FRAME) {
|
|
1067
|
+
throw new CompileError(
|
|
1068
|
+
`animation "${animName}" declares duration ${anim.duration}s but its last key is at ${compiledDuration}s`,
|
|
1069
|
+
);
|
|
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.
|
|
1074
|
+
animations[animName] = {};
|
|
1075
|
+
if (Object.keys(slotTimelines).length) animations[animName].slots = slotTimelines;
|
|
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
|
+
}
|
|
1081
|
+
if (Object.keys(physicsTimelines).length) animations[animName].physics = physicsTimelines;
|
|
1082
|
+
if (Object.keys(deformTimelines).length) animations[animName].attachments = deformTimelines;
|
|
1083
|
+
if (drawOrder) animations[animName].drawOrder = drawOrder;
|
|
1084
|
+
if (eventKeys) animations[animName].events = eventKeys;
|
|
845
1085
|
}
|
|
846
|
-
|
|
847
|
-
if (Object.keys(slotTimelines).length) animations[animName].slots = slotTimelines;
|
|
848
|
-
if (Object.keys(boneTimelines).length) animations[animName].bones = boneTimelines;
|
|
849
|
-
if (Object.keys(physicsTimelines).length) animations[animName].physics = physicsTimelines;
|
|
850
|
-
if (drawOrder) animations[animName].drawOrder = drawOrder;
|
|
851
|
-
if (eventKeys) animations[animName].events = eventKeys;
|
|
852
|
-
}
|
|
1086
|
+
});
|
|
853
1087
|
|
|
854
1088
|
// -- 6. assemble -----------------------------------------------------------
|
|
855
1089
|
const header: SpineSkeletonJson['skeleton'] = {
|
|
@@ -2063,6 +2297,431 @@ function compileEvents(
|
|
|
2063
2297
|
return out;
|
|
2064
2298
|
}
|
|
2065
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
|
+
|
|
2066
2725
|
function resolveTargets(track: MotionTrack, motion: MotionSpec, animName: string): string[] {
|
|
2067
2726
|
const named = [track.slot, track.group, track.bone, track.physics].filter((v) => v !== undefined);
|
|
2068
2727
|
if (named.length > 1) {
|