spine-rigc 0.2.1 → 0.3.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 +231 -6
- package/cli.ts +39 -5
- package/docs/AUTHORING.md +611 -24
- package/docs/SPEC_COVERAGE.md +16 -10
- package/package.json +5 -2
- package/src/chains.ts +170 -0
- package/src/check.ts +1013 -92
- package/src/compile.ts +325 -6
- package/src/ladder.ts +1 -1
- package/src/render.ts +22 -2
- package/src/rig.ts +169 -6
- package/src/timelines.ts +9 -5
- package/src/types.ts +80 -1
- package/src/validate.ts +192 -2
package/src/compile.ts
CHANGED
|
@@ -25,10 +25,14 @@ import {
|
|
|
25
25
|
parseRigSpec,
|
|
26
26
|
type RigAttachment,
|
|
27
27
|
type RigBone,
|
|
28
|
+
type RigBoundingBoxAttachment,
|
|
29
|
+
type RigClippingAttachment,
|
|
30
|
+
type RigEvent,
|
|
28
31
|
type RigMeshAttachment,
|
|
29
32
|
type RigMeshBinding,
|
|
30
33
|
type RigRegionAttachment,
|
|
31
34
|
type RigSpec,
|
|
35
|
+
type RigVertexGeometry,
|
|
32
36
|
} from './rig.ts';
|
|
33
37
|
import { buildRibbonMesh, buildRingMesh, encodeWeightedVertices, MeshError, type MeshBoneRef } from './mesh.ts';
|
|
34
38
|
import { KEY_TIME_EPSILON } from './timelines.ts';
|
|
@@ -48,12 +52,16 @@ import type {
|
|
|
48
52
|
FaceManifest,
|
|
49
53
|
FaceManifestPart,
|
|
50
54
|
MotionDrawOrderKey,
|
|
55
|
+
MotionEventKey,
|
|
51
56
|
MotionSpec,
|
|
52
57
|
MotionTrack,
|
|
53
58
|
RigInfo,
|
|
54
59
|
SpineAttachment,
|
|
55
60
|
SpineBone,
|
|
61
|
+
SpineBoundingBoxAttachment,
|
|
62
|
+
SpineClippingAttachment,
|
|
56
63
|
SpineConstraint,
|
|
64
|
+
SpineEvent,
|
|
57
65
|
SpineMeshAttachment,
|
|
58
66
|
SpineRegionAttachment,
|
|
59
67
|
SpineSkeletonJson,
|
|
@@ -77,6 +85,52 @@ function r6(n: number): number {
|
|
|
77
85
|
return v === 0 ? 0 : v;
|
|
78
86
|
}
|
|
79
87
|
|
|
88
|
+
/**
|
|
89
|
+
* A **key time** on that same 1e-6 s grid — rounded DOWN rather than to nearest.
|
|
90
|
+
*
|
|
91
|
+
* ## Why key times get their own quantiser
|
|
92
|
+
*
|
|
93
|
+
* Every other emitted number is a quantity, and for a quantity nearest is the
|
|
94
|
+
* least wrong answer. A key time is not a quantity: it is a **position against a
|
|
95
|
+
* sample grid a player will step**, and the two directions of a half-step error
|
|
96
|
+
* are not equally wrong. Rounded down, a key fires on the sample it was written
|
|
97
|
+
* for, half a millionth of a second early, and nothing can see it. Rounded up, it
|
|
98
|
+
* fires on the NEXT sample — a whole frame late — and on a stepped timeline that
|
|
99
|
+
* is the wrong picture rather than a slightly wrong value.
|
|
100
|
+
*
|
|
101
|
+
* The arithmetic is not exotic, it is the common case: `2/12 s` and `5/30 s` are
|
|
102
|
+
* both 0.16666666…, `r6` emits 0.166667, and 0.166667 is larger than either. The
|
|
103
|
+
* spineboy run's muzzle flare fired one 12 fps frame late for exactly that, with
|
|
104
|
+
* no error and no warning, until the run's own frame self-check caught it (issue
|
|
105
|
+
* #99). ⚠️ An attachment timeline is inherently stepped, so it is where this
|
|
106
|
+
* surfaces first — but a rotate key rounded up is a frame late too; it just hides
|
|
107
|
+
* inside the interpolation.
|
|
108
|
+
*
|
|
109
|
+
* ## Why this is not `Math.floor(n * 1e6)`
|
|
110
|
+
*
|
|
111
|
+
* `n * 1e6` is itself a rounded double: `0.7 * 1e6` is 699999.9999999999, and
|
|
112
|
+
* flooring it would move a time the grid represents **exactly** a whole step down.
|
|
113
|
+
* So the value is rounded to nearest first and stepped back only when the result
|
|
114
|
+
* genuinely overshoots the time it came from. A time already on the grid is
|
|
115
|
+
* therefore emitted unchanged, which is what keeps `A18_DETERMINISTIC_EMIT` and
|
|
116
|
+
* every committed artifact where they were.
|
|
117
|
+
*
|
|
118
|
+
* ## What it means for `KEY_TIME_EPSILON`
|
|
119
|
+
*
|
|
120
|
+
* The tolerance's job narrows rather than moves: an authored key can no longer
|
|
121
|
+
* land past its own `duration` by the compiler's own rounding, so `checkKeyTime`
|
|
122
|
+
* only refuses a key the author really did put past the end. The epsilon stays,
|
|
123
|
+
* because A09 re-checks the same rule on an emitted file read back through a
|
|
124
|
+
* Float32Array — where the grid is coarser and rounds both ways — and because a
|
|
125
|
+
* `duration` is not required to be on the grid either.
|
|
126
|
+
*/
|
|
127
|
+
function keyTime(n: number): number {
|
|
128
|
+
let units = Math.round(n * 1e6);
|
|
129
|
+
if (units / 1e6 > n) units -= 1;
|
|
130
|
+
const v = units / 1e6;
|
|
131
|
+
return v === 0 ? 0 : v;
|
|
132
|
+
}
|
|
133
|
+
|
|
80
134
|
/**
|
|
81
135
|
* Rule 4, per timeline: no key may land past the animation's declared duration.
|
|
82
136
|
*
|
|
@@ -86,6 +140,12 @@ function r6(n: number): number {
|
|
|
86
140
|
* That is exactly how rung 6 lost a one-frame attachment reveal; the tolerance
|
|
87
141
|
* story is in `KEY_TIME_EPSILON`.
|
|
88
142
|
*
|
|
143
|
+
* ⚠️ It compares the **emitted** time, not the authored one, and since `keyTime`
|
|
144
|
+
* rounds down that can only be more forgiving than comparing the author's number
|
|
145
|
+
* — by less than one step of the grid. That is the honest side to err on: the
|
|
146
|
+
* emitted time is the one a player samples, and refusing a key that will in fact
|
|
147
|
+
* be reached would be refusing a correct animation.
|
|
148
|
+
*
|
|
89
149
|
* This is a refusal rather than an assertion because the key is the thing to
|
|
90
150
|
* change and the motion spec is the file it lives in: the message has to name
|
|
91
151
|
* the track and the key, and by gate time both are gone — the emitted skeleton
|
|
@@ -638,6 +698,7 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
638
698
|
meshes,
|
|
639
699
|
slotName: rigSlot.name,
|
|
640
700
|
anchorBone: rigSlot.bone,
|
|
701
|
+
slotNames: new Set(rig.slots.map((s) => s.name)),
|
|
641
702
|
});
|
|
642
703
|
}
|
|
643
704
|
tableFor(skinName)[rigSlot.name] = perSlot;
|
|
@@ -762,6 +823,12 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
762
823
|
const drawOrder = anim.drawOrder ? compileDrawOrder(anim.drawOrder, animName, anim.duration, slots) : null;
|
|
763
824
|
if (drawOrder) for (const key of drawOrder) compiledDuration = Math.max(compiledDuration, key.time as number);
|
|
764
825
|
|
|
826
|
+
const eventKeys = anim.events ? compileEvents(anim.events, animName, anim.duration, rig.events ?? {}) : null;
|
|
827
|
+
// An event timeline counts towards the animation's length the same as any
|
|
828
|
+
// other: `readAnimation` takes the duration from the longest timeline it
|
|
829
|
+
// built, and `EventTimeline.getDuration()` is its last frame like the rest.
|
|
830
|
+
if (eventKeys) for (const key of eventKeys) compiledDuration = Math.max(compiledDuration, key.time as number);
|
|
831
|
+
|
|
765
832
|
// Rule 4: the declared duration is verified, because skeleton JSON does not
|
|
766
833
|
// carry one — the loader takes the max key time.
|
|
767
834
|
//
|
|
@@ -781,6 +848,7 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
781
848
|
if (Object.keys(boneTimelines).length) animations[animName].bones = boneTimelines;
|
|
782
849
|
if (Object.keys(physicsTimelines).length) animations[animName].physics = physicsTimelines;
|
|
783
850
|
if (drawOrder) animations[animName].drawOrder = drawOrder;
|
|
851
|
+
if (eventKeys) animations[animName].events = eventKeys;
|
|
784
852
|
}
|
|
785
853
|
|
|
786
854
|
// -- 6. assemble -----------------------------------------------------------
|
|
@@ -795,11 +863,29 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
795
863
|
if (rig.skeleton?.referenceScale !== undefined) header.referenceScale = rig.skeleton.referenceScale;
|
|
796
864
|
if (rig.skeleton?.images !== undefined) header.images = rig.skeleton.images;
|
|
797
865
|
|
|
866
|
+
// Event definitions. Emitted in the order the rig spec declares them — object
|
|
867
|
+
// key order is the spec's, not a set's, so A18 stays a contract.
|
|
868
|
+
const events: Record<string, SpineEvent> = {};
|
|
869
|
+
for (const [name, def] of Object.entries(rig.events ?? {})) {
|
|
870
|
+
const entry: SpineEvent = {};
|
|
871
|
+
if (def.int !== undefined) entry.int = def.int;
|
|
872
|
+
if (def.float !== undefined) entry.float = r6(def.float);
|
|
873
|
+
if (def.string !== undefined) entry.string = def.string;
|
|
874
|
+
if (def.audio !== undefined) entry.audio = def.audio;
|
|
875
|
+
if (def.volume !== undefined) entry.volume = r6(def.volume);
|
|
876
|
+
if (def.balance !== undefined) entry.balance = r6(def.balance);
|
|
877
|
+
events[name] = entry;
|
|
878
|
+
}
|
|
879
|
+
|
|
798
880
|
const skeleton: SpineSkeletonJson = {
|
|
799
881
|
skeleton: header,
|
|
800
882
|
bones,
|
|
801
883
|
slots,
|
|
802
884
|
skins: [...skinTables.entries()].map(([name, attachments]) => ({ name, attachments })),
|
|
885
|
+
// Between `skins` and `animations`, which is where the editor writes it. A
|
|
886
|
+
// conditional spread rather than an assignment after the literal, so the key
|
|
887
|
+
// lands in that position instead of at the end.
|
|
888
|
+
...(Object.keys(events).length ? { events } : {}),
|
|
803
889
|
animations,
|
|
804
890
|
};
|
|
805
891
|
if (constraints.length) skeleton.constraints = constraints;
|
|
@@ -961,6 +1047,8 @@ interface AttachmentContext {
|
|
|
961
1047
|
meshes: CompileResult['meshes'];
|
|
962
1048
|
slotName: string;
|
|
963
1049
|
anchorBone: string;
|
|
1050
|
+
/** Every slot the rig declares — a clipping attachment's `end` resolves here. */
|
|
1051
|
+
slotNames: Set<string>;
|
|
964
1052
|
}
|
|
965
1053
|
|
|
966
1054
|
/**
|
|
@@ -981,12 +1069,149 @@ function buildRigAttachment(
|
|
|
981
1069
|
const type = att.type ?? 'region';
|
|
982
1070
|
if (type === 'region') return buildRigRegion(att as RigRegionAttachment, placeholder, where, ctx);
|
|
983
1071
|
if (type === 'mesh') return buildRigMesh(att as RigMeshAttachment, where, ctx);
|
|
1072
|
+
if (type === 'boundingbox') return buildRigBoundingBox(att as RigBoundingBoxAttachment, where, ctx);
|
|
1073
|
+
if (type === 'clipping') return buildRigClipping(att as RigClippingAttachment, where, ctx);
|
|
984
1074
|
throw new NotImplementedError(
|
|
985
1075
|
`${where}: attachment type "${String(type)}" is in the Spine 4.3 format and rigc does not emit it yet. ` +
|
|
986
|
-
'Implemented: region, mesh
|
|
1076
|
+
'Implemented: region, mesh, boundingbox, clipping. ' +
|
|
1077
|
+
'point, path and linkedmesh are deliberately deferred: not one of them appears anywhere in the benchmark ' +
|
|
1078
|
+
'corpus (docs/SPEC_COVERAGE.md parts 3-1 and 4-2), so none is on the ladder\'s critical path. ' +
|
|
1079
|
+
'docs/SPEC_COVERAGE.md part 1-6 lists what each type would have to carry.',
|
|
987
1080
|
);
|
|
988
1081
|
}
|
|
989
1082
|
|
|
1083
|
+
/**
|
|
1084
|
+
* Encode the polygon a bounding box or a clipping attachment carries.
|
|
1085
|
+
*
|
|
1086
|
+
* 🚨 `vertexCount` is required, and everything else here is a cross-check of it.
|
|
1087
|
+
* The parser reads `map.vertexCount << 1` and hands that to `readVertices` as the
|
|
1088
|
+
* length to expect (`:552`, `:632`) — so with the field absent it expects 0,
|
|
1089
|
+
* takes the weighted branch, decodes the coordinate list as
|
|
1090
|
+
* `boneCount, (index, x, y, weight) × n`, and returns an attachment holding
|
|
1091
|
+
* whatever that garbage produced. It loads. It draws nothing (a bounding box
|
|
1092
|
+
* never did) and clips nothing, or clips the wrong shape, in complete silence.
|
|
1093
|
+
*
|
|
1094
|
+
* The two encodings are the mesh's — `encodeNamedWeights` is the same function —
|
|
1095
|
+
* because they are the same field with the same trap: `readVertices` decides
|
|
1096
|
+
* weighted vs unweighted by a length comparison alone, and a coincidental match
|
|
1097
|
+
* reads weight data as coordinates.
|
|
1098
|
+
*/
|
|
1099
|
+
function buildVertexGeometry(att: RigVertexGeometry, where: string, ctx: AttachmentContext): number[] {
|
|
1100
|
+
const count = att.vertexCount;
|
|
1101
|
+
if (typeof count !== 'number' || !Number.isInteger(count) || count < 3) {
|
|
1102
|
+
throw new CompileError(
|
|
1103
|
+
`${where}: vertexCount is ${JSON.stringify(count)}; a polygon needs at least 3 vertices, stated outright — ` +
|
|
1104
|
+
'the field has no parser default, and an absent one reads as 0 and takes the polygon with it',
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
if (att.vertices && att.weights) {
|
|
1108
|
+
throw new CompileError(
|
|
1109
|
+
`${where}: geometry comes as "vertices" or as "weights", never both — "weights" is the by-name form of the same data`,
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
if (att.weights) {
|
|
1113
|
+
if (att.boneIndexing === 'raw') {
|
|
1114
|
+
throw new CompileError(`${where}: "boneIndexing": "raw" describes a "vertices" run; "weights" always binds by name`);
|
|
1115
|
+
}
|
|
1116
|
+
if (att.weights.length !== count) {
|
|
1117
|
+
throw new CompileError(`${where}: weights cover ${att.weights.length} vertices but vertexCount is ${count}`);
|
|
1118
|
+
}
|
|
1119
|
+
return encodeNamedWeights(att.weights, where, ctx);
|
|
1120
|
+
}
|
|
1121
|
+
const raw = att.vertices;
|
|
1122
|
+
if (!raw || raw.length === 0) {
|
|
1123
|
+
throw new CompileError(`${where}: needs geometry — "vertices" (x, y per vertex) or "weights" (bound by name)`);
|
|
1124
|
+
}
|
|
1125
|
+
const unweighted = raw.length === count * 2;
|
|
1126
|
+
if (!unweighted) {
|
|
1127
|
+
if (att.boneIndexing !== 'raw') {
|
|
1128
|
+
throw new CompileError(
|
|
1129
|
+
`${where}: vertexCount ${count} wants ${count * 2} unweighted numbers and "vertices" holds ${raw.length}. ` +
|
|
1130
|
+
'The parser reads that as a WEIGHTED run, whose bone INDEXES point into the emitted bone array — a list ' +
|
|
1131
|
+
'the spec never writes, so inserting a bone rebinds every vertex in silence. ' +
|
|
1132
|
+
'Give the bindings by name as "weights": [[{ "bone": …, "x": …, "y": …, "weight": … }, …], …], ' +
|
|
1133
|
+
'fix vertexCount, or say "boneIndexing": "raw" on this attachment to keep the index form deliberately.',
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
// A raw run still has to decode to exactly `vertexCount` vertices, or the
|
|
1137
|
+
// count and the polygon disagree and the parser believes the count.
|
|
1138
|
+
let decoded = 0;
|
|
1139
|
+
for (let i = 0; i < raw.length; decoded++) {
|
|
1140
|
+
const bones = raw[i++];
|
|
1141
|
+
if (!Number.isInteger(bones) || bones < 1) {
|
|
1142
|
+
throw new CompileError(`${where}: the raw weighted run has a bone count of ${String(bones)} at index ${i - 1}`);
|
|
1143
|
+
}
|
|
1144
|
+
i += bones * 4;
|
|
1145
|
+
if (i > raw.length) {
|
|
1146
|
+
throw new CompileError(
|
|
1147
|
+
`${where}: the raw weighted run is truncated — vertex ${decoded} claims ${bones} bone(s) and the array ends first`,
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
if (decoded !== count) {
|
|
1152
|
+
throw new CompileError(`${where}: the raw weighted run decodes to ${decoded} vertices but vertexCount is ${count}`);
|
|
1153
|
+
}
|
|
1154
|
+
// Register the bones it binds so the mesh-bone reports stay complete.
|
|
1155
|
+
for (let i = 0; i < raw.length; ) {
|
|
1156
|
+
const bones = raw[i++];
|
|
1157
|
+
for (let k = 0; k < bones; k++, i += 4) {
|
|
1158
|
+
const bone = ctx.bones[raw[i]];
|
|
1159
|
+
if (bone) ctx.meshBones.add(bone.name);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
for (const n of raw) {
|
|
1164
|
+
if (!Number.isFinite(n)) throw new CompileError(`${where}: the vertex array holds a non-finite value ${String(n)}`);
|
|
1165
|
+
}
|
|
1166
|
+
return raw.map(r6);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function buildRigBoundingBox(
|
|
1170
|
+
att: RigBoundingBoxAttachment,
|
|
1171
|
+
where: string,
|
|
1172
|
+
ctx: AttachmentContext,
|
|
1173
|
+
): SpineBoundingBoxAttachment {
|
|
1174
|
+
const out: SpineBoundingBoxAttachment = {
|
|
1175
|
+
type: 'boundingbox',
|
|
1176
|
+
vertexCount: att.vertexCount,
|
|
1177
|
+
vertices: buildVertexGeometry(att, where, ctx),
|
|
1178
|
+
};
|
|
1179
|
+
if (att.color !== undefined) out.color = att.color;
|
|
1180
|
+
return out;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function buildRigClipping(
|
|
1184
|
+
att: RigClippingAttachment,
|
|
1185
|
+
where: string,
|
|
1186
|
+
ctx: AttachmentContext,
|
|
1187
|
+
): SpineClippingAttachment {
|
|
1188
|
+
if (att.end !== undefined) {
|
|
1189
|
+
// `skeletonData.findSlot` returns null on a miss and the parser assigns that
|
|
1190
|
+
// null (`:626-627`), so a typo does not fail — the clip simply never ends and
|
|
1191
|
+
// takes every slot below it out of the frame.
|
|
1192
|
+
if (typeof att.end !== 'string' || !ctx.slotNames.has(att.end)) {
|
|
1193
|
+
throw new CompileError(
|
|
1194
|
+
`${where}: end names slot ${JSON.stringify(att.end)}, which this rig does not declare; ` +
|
|
1195
|
+
'a miss loads as null and the clip then runs to the bottom of the draw order',
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
// Field order is the editor's here — `end`, `convex`, `inverse` before the
|
|
1200
|
+
// geometry — via conditional spreads, because a key assigned after the literal
|
|
1201
|
+
// lands at the end instead. Order carries no meaning in JSON; it is read by
|
|
1202
|
+
// people, and this file's diff against a reference is read a lot.
|
|
1203
|
+
const out: SpineClippingAttachment = {
|
|
1204
|
+
type: 'clipping',
|
|
1205
|
+
...(att.end !== undefined ? { end: att.end } : {}),
|
|
1206
|
+
...(att.convex !== undefined ? { convex: att.convex } : {}),
|
|
1207
|
+
...(att.inverse !== undefined ? { inverse: att.inverse } : {}),
|
|
1208
|
+
vertexCount: att.vertexCount,
|
|
1209
|
+
vertices: buildVertexGeometry(att, where, ctx),
|
|
1210
|
+
};
|
|
1211
|
+
if (att.color !== undefined) out.color = att.color;
|
|
1212
|
+
return out;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
990
1215
|
function buildRigRegion(
|
|
991
1216
|
att: RigRegionAttachment,
|
|
992
1217
|
placeholder: string,
|
|
@@ -1606,7 +1831,7 @@ function compileValueTrack(
|
|
|
1606
1831
|
for (let i = 0; i < track.keys.length; i++) {
|
|
1607
1832
|
const key = track.keys[i];
|
|
1608
1833
|
const next = track.keys[i + 1];
|
|
1609
|
-
const time =
|
|
1834
|
+
const time = keyTime(key.t + shift);
|
|
1610
1835
|
if (i > 0 && time <= (out[i - 1].time as number)) {
|
|
1611
1836
|
throw new CompileError(`${where}: key times must strictly increase (at t=${key.t})`);
|
|
1612
1837
|
}
|
|
@@ -1641,7 +1866,7 @@ function compileValueTrack(
|
|
|
1641
1866
|
const handles = motion.easings?.[key.ease];
|
|
1642
1867
|
if (!handles) throw new CompileError(`${where}: unknown easing "${key.ease}"`);
|
|
1643
1868
|
if (!Array.isArray(next.v)) throw new CompileError(`${where}: next key value must be an array`);
|
|
1644
|
-
const t2 =
|
|
1869
|
+
const t2 = keyTime(next.t + shift);
|
|
1645
1870
|
const curve: number[] = [];
|
|
1646
1871
|
for (let c = 0; c < shape.fields.length; c++) {
|
|
1647
1872
|
curve.push(...bezierForChannel(handles, time, t2, (key.v as number[])[c], (next.v as number[])[c]));
|
|
@@ -1703,7 +1928,7 @@ function compileDrawOrder(
|
|
|
1703
1928
|
const out: SpineTimelineKey[] = [];
|
|
1704
1929
|
for (let i = 0; i < keys.length; i++) {
|
|
1705
1930
|
const key = keys[i];
|
|
1706
|
-
const time =
|
|
1931
|
+
const time = keyTime(key.t);
|
|
1707
1932
|
if (i > 0 && time <= (out[i - 1].time as number)) {
|
|
1708
1933
|
throw new CompileError(`${where}: key times must strictly increase (at t=${key.t})`);
|
|
1709
1934
|
}
|
|
@@ -1744,6 +1969,100 @@ function compileDrawOrder(
|
|
|
1744
1969
|
return out;
|
|
1745
1970
|
}
|
|
1746
1971
|
|
|
1972
|
+
/**
|
|
1973
|
+
* The whole-animation event timeline (`animations.<a>.events`).
|
|
1974
|
+
*
|
|
1975
|
+
* Four refusals, and the reason each one is here is a different failure mode of
|
|
1976
|
+
* `readAnimation`'s event branch (SkeletonJson.ts:1238-1261):
|
|
1977
|
+
*
|
|
1978
|
+
* 1. **The name must be declared.** `skeletonData.findEvent` returns null and
|
|
1979
|
+
* the parser throws `Event not found` — one of the format's few loud
|
|
1980
|
+
* failures, but it throws in the CONSUMER's process, which is late. Refused
|
|
1981
|
+
* here so the message can name the rig spec's `events` block instead.
|
|
1982
|
+
* 2. **Key times must not go backwards.** The loop writes frame `i` from key
|
|
1983
|
+
* `i` in ARRAY order and never sorts, so a time that decreases produces an
|
|
1984
|
+
* `EventTimeline` whose frames are out of order. Nothing throws; the
|
|
1985
|
+
* timeline's search simply stops finding the firings behind the fold. Equal
|
|
1986
|
+
* times are legal and deliberate — two different events on the same frame is
|
|
1987
|
+
* an ordinary thing to want — so this is non-decreasing, not the strictly
|
|
1988
|
+
* increasing rule a value track lives under (a value track has one value per
|
|
1989
|
+
* time and two keys at one time is a contradiction; two firings are not).
|
|
1990
|
+
* 3. **`volume`/`balance` need the event to carry `audio`.** `:1254-1257` reads
|
|
1991
|
+
* them only inside `if (event.data.audioPath)`, so on a silent event they
|
|
1992
|
+
* are dropped without a word.
|
|
1993
|
+
* 4. **A key past the declared duration.** The same `checkKeyTime` every other
|
|
1994
|
+
* timeline goes through: a firing after the end never fires (issue #54 was
|
|
1995
|
+
* exactly this shape on an attachment reveal).
|
|
1996
|
+
*/
|
|
1997
|
+
function compileEvents(
|
|
1998
|
+
keys: MotionEventKey[],
|
|
1999
|
+
animName: string,
|
|
2000
|
+
duration: number,
|
|
2001
|
+
events: Record<string, RigEvent>,
|
|
2002
|
+
): SpineTimelineKey[] {
|
|
2003
|
+
const where = `animation "${animName}" events`;
|
|
2004
|
+
if (!keys.length) throw new CompileError(`${where}: no keys`);
|
|
2005
|
+
|
|
2006
|
+
const out: SpineTimelineKey[] = [];
|
|
2007
|
+
for (let i = 0; i < keys.length; i++) {
|
|
2008
|
+
const key = keys[i];
|
|
2009
|
+
if (typeof key.name !== 'string' || key.name.length === 0) {
|
|
2010
|
+
throw new CompileError(`${where}: key ${i} has no "name"; an event key fires an event by name`);
|
|
2011
|
+
}
|
|
2012
|
+
const declared = events[key.name];
|
|
2013
|
+
if (declared === undefined) {
|
|
2014
|
+
const known = Object.keys(events);
|
|
2015
|
+
throw new CompileError(
|
|
2016
|
+
`${where} at t=${key.t}: event "${key.name}" is not declared in the rig spec's "events" block; ` +
|
|
2017
|
+
(known.length ? `declared: ${known.join(', ')}` : 'that block is empty or absent'),
|
|
2018
|
+
);
|
|
2019
|
+
}
|
|
2020
|
+
if (!Number.isFinite(key.t)) throw new CompileError(`${where}: key ${i} has a non-finite time ${String(key.t)}`);
|
|
2021
|
+
const time = keyTime(key.t);
|
|
2022
|
+
if (i > 0 && time < (out[i - 1].time as number)) {
|
|
2023
|
+
throw new CompileError(
|
|
2024
|
+
`${where}: key times must not go backwards (at t=${key.t}, after t=${String(out[i - 1].time)}) — ` +
|
|
2025
|
+
'the parser writes frames in array order and never sorts them',
|
|
2026
|
+
);
|
|
2027
|
+
}
|
|
2028
|
+
checkKeyTime(where, time, key.t, duration);
|
|
2029
|
+
|
|
2030
|
+
const entry: SpineTimelineKey = { time, name: key.name };
|
|
2031
|
+
if (key.int !== undefined) {
|
|
2032
|
+
if (!Number.isInteger(key.int)) {
|
|
2033
|
+
throw new CompileError(`${where} at t=${key.t}: int ${String(key.int)} is not an integer`);
|
|
2034
|
+
}
|
|
2035
|
+
entry.int = key.int;
|
|
2036
|
+
}
|
|
2037
|
+
if (key.float !== undefined) {
|
|
2038
|
+
if (!Number.isFinite(key.float)) {
|
|
2039
|
+
throw new CompileError(`${where} at t=${key.t}: float ${String(key.float)} is not finite`);
|
|
2040
|
+
}
|
|
2041
|
+
entry.float = r6(key.float);
|
|
2042
|
+
}
|
|
2043
|
+
if (key.string !== undefined) {
|
|
2044
|
+
if (typeof key.string !== 'string') {
|
|
2045
|
+
throw new CompileError(`${where} at t=${key.t}: string ${JSON.stringify(key.string)} is not a string`);
|
|
2046
|
+
}
|
|
2047
|
+
entry.string = key.string;
|
|
2048
|
+
}
|
|
2049
|
+
for (const field of ['volume', 'balance'] as const) {
|
|
2050
|
+
const v = key[field];
|
|
2051
|
+
if (v === undefined) continue;
|
|
2052
|
+
if (declared.audio === undefined) {
|
|
2053
|
+
throw new CompileError(
|
|
2054
|
+
`${where} at t=${key.t}: ${field} is set but event "${key.name}" declares no "audio"; ` +
|
|
2055
|
+
`the parser reads ${field} only for an event with an audio path, so it would be dropped in silence`,
|
|
2056
|
+
);
|
|
2057
|
+
}
|
|
2058
|
+
if (!Number.isFinite(v)) throw new CompileError(`${where} at t=${key.t}: ${field} ${String(v)} is not finite`);
|
|
2059
|
+
entry[field] = r6(v);
|
|
2060
|
+
}
|
|
2061
|
+
out.push(entry);
|
|
2062
|
+
}
|
|
2063
|
+
return out;
|
|
2064
|
+
}
|
|
2065
|
+
|
|
1747
2066
|
function resolveTargets(track: MotionTrack, motion: MotionSpec, animName: string): string[] {
|
|
1748
2067
|
const named = [track.slot, track.group, track.bone, track.physics].filter((v) => v !== undefined);
|
|
1749
2068
|
if (named.length > 1) {
|
|
@@ -1803,7 +2122,7 @@ function compileTrack(
|
|
|
1803
2122
|
for (let i = 0; i < track.keys.length; i++) {
|
|
1804
2123
|
const key = track.keys[i];
|
|
1805
2124
|
const next = track.keys[i + 1];
|
|
1806
|
-
const time =
|
|
2125
|
+
const time = keyTime(key.t + shift);
|
|
1807
2126
|
if (i > 0 && time <= (out[i - 1].time as number)) {
|
|
1808
2127
|
throw new CompileError(`${where}: key times must strictly increase (at t=${key.t})`);
|
|
1809
2128
|
}
|
|
@@ -1843,7 +2162,7 @@ function compileTrack(
|
|
|
1843
2162
|
if (!Array.isArray(next.v)) {
|
|
1844
2163
|
throw new CompileError(`${where}: rgba key value must be [r,g,b,a]`);
|
|
1845
2164
|
}
|
|
1846
|
-
const t2 =
|
|
2165
|
+
const t2 = keyTime(next.t + shift);
|
|
1847
2166
|
// 4 numbers per channel, r g b a — 16 in total. Short arrays become NaN
|
|
1848
2167
|
// curves with no error.
|
|
1849
2168
|
const curve: number[] = [];
|
package/src/ladder.ts
CHANGED
|
@@ -103,7 +103,7 @@ export const LADDER: readonly Rung[] = [
|
|
|
103
103
|
{
|
|
104
104
|
id: 'spineboy',
|
|
105
105
|
example: 'spineboy',
|
|
106
|
-
gates: 'IK, events, bounding box, clipping, unweighted meshes — and scale:
|
|
106
|
+
gates: 'IK, events, bounding box, clipping, unweighted meshes — and scale: ess 18 bones/20 slots/8 animations, pro 67 bones/52 slots/11 animations',
|
|
107
107
|
skeletons: [
|
|
108
108
|
{ label: 'ess', file: 'spineboy-ess.json', atlas: 'spineboy.atlas', role: 'rung' },
|
|
109
109
|
// `-pro` is reported and does not count. It is a harder rig than the
|
package/src/render.ts
CHANGED
|
@@ -909,6 +909,17 @@ export interface FrameGeometry {
|
|
|
909
909
|
/** 1 where any piece drew, in `viewport.width * viewport.height` row-major order. */
|
|
910
910
|
coverage: Uint8Array;
|
|
911
911
|
footprints: Map<string, Footprint>;
|
|
912
|
+
/**
|
|
913
|
+
* Which owner drew each pixel last, or `-1` — `null` unless `owners` was given.
|
|
914
|
+
*
|
|
915
|
+
* "Last" is the composite's own rule: pieces arrive in draw order, so the owner
|
|
916
|
+
* left in a pixel is the one you would see there. That is deliberately the
|
|
917
|
+
* opposite of `footprints`, which measures each slot on its own pixels
|
|
918
|
+
* *ignoring* what covers it — a footprint answers "where is this part", and
|
|
919
|
+
* this mask answers "whose part is this pixel", and only the second one can be
|
|
920
|
+
* a partition.
|
|
921
|
+
*/
|
|
922
|
+
owner: Int32Array | null;
|
|
912
923
|
}
|
|
913
924
|
|
|
914
925
|
/**
|
|
@@ -923,11 +934,19 @@ export interface FrameGeometry {
|
|
|
923
934
|
* an occluded part merges into its occluder's component — and that is what the
|
|
924
935
|
* matcher reports as ambiguity rather than as drift.
|
|
925
936
|
*/
|
|
926
|
-
export function frameGeometry(
|
|
937
|
+
export function frameGeometry(
|
|
938
|
+
frame: Frame,
|
|
939
|
+
pages: Map<string, Plate>,
|
|
940
|
+
viewport: Viewport,
|
|
941
|
+
/** Slot name → owner id, when the caller also wants the per-pixel owner mask. */
|
|
942
|
+
owners?: Map<string, number>,
|
|
943
|
+
): FrameGeometry {
|
|
927
944
|
const coverage = new Uint8Array(viewport.width * viewport.height);
|
|
945
|
+
const owner = owners === undefined ? null : new Int32Array(viewport.width * viewport.height).fill(-1);
|
|
928
946
|
const footprints = new Map<string, Footprint>();
|
|
929
947
|
const project = projector(viewport);
|
|
930
948
|
for (const piece of frame.pieces) {
|
|
949
|
+
const owned = owners === undefined ? -1 : (owners.get(piece.slot) ?? -1);
|
|
931
950
|
let weight = 0;
|
|
932
951
|
let sx = 0;
|
|
933
952
|
let sy = 0;
|
|
@@ -937,6 +956,7 @@ export function frameGeometry(frame: Frame, pages: Map<string, Plate>, viewport:
|
|
|
937
956
|
let maxY = -Infinity;
|
|
938
957
|
rasterisePiece(pageFor(pages, piece), piece, project, viewport, (px, py, _r, _g, _b, a) => {
|
|
939
958
|
coverage[py * viewport.width + px] = 1;
|
|
959
|
+
if (owner !== null && owned >= 0) owner[py * viewport.width + px] = owned;
|
|
940
960
|
const w = a / 255;
|
|
941
961
|
weight += w;
|
|
942
962
|
sx += (px + 0.5) * w;
|
|
@@ -956,7 +976,7 @@ export function frameGeometry(frame: Frame, pages: Map<string, Plate>, viewport:
|
|
|
956
976
|
// answer, and it keeps the map keyed by slot the way the report reads it.
|
|
957
977
|
footprints.set(piece.slot, previous && previous.pixels > 0 ? mergeFootprints(previous, here) : here);
|
|
958
978
|
}
|
|
959
|
-
return { coverage, footprints };
|
|
979
|
+
return { coverage, footprints, owner };
|
|
960
980
|
}
|
|
961
981
|
|
|
962
982
|
function mergeFootprints(a: Footprint, b: Footprint): Footprint {
|