spine-rigc 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE.md +76 -0
- package/README.md +558 -0
- package/cli.ts +739 -0
- package/docs/AUTHORING.md +1303 -0
- package/docs/SPEC_COVERAGE.md +1109 -0
- package/package.json +65 -0
- package/src/check.ts +1714 -0
- package/src/compile.ts +1861 -0
- package/src/diff.ts +847 -0
- package/src/errors.ts +22 -0
- package/src/framing.ts +539 -0
- package/src/ladder.ts +121 -0
- package/src/mesh.ts +433 -0
- package/src/png.ts +50 -0
- package/src/render.ts +974 -0
- package/src/rig.ts +731 -0
- package/src/slots.ts +603 -0
- package/src/timelines.ts +253 -0
- package/src/transform.ts +130 -0
- package/src/types.ts +586 -0
- package/src/validate.ts +1586 -0
- package/tools/font5x7.ts +101 -0
- package/tools/plate.ts +286 -0
package/src/timelines.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The 4.3 animation timeline catalogue, as data plus one walker.
|
|
3
|
+
*
|
|
4
|
+
* This lives on its own because two very different consumers need the same
|
|
5
|
+
* enumeration and neither may drift from the other: `validate.ts` walks it to
|
|
6
|
+
* check curve arrays (A05) and two-colour timelines (A12), and `diff.ts` walks
|
|
7
|
+
* it to count what a rig actually keys. When it was inlined in the validator,
|
|
8
|
+
* "which groups exist" was stated in one place and the comparison tool would
|
|
9
|
+
* have had to restate it — and a second copy of a catalogue is a second copy
|
|
10
|
+
* that goes stale silently.
|
|
11
|
+
*
|
|
12
|
+
* Pure JSON reading. No spine-core, no filesystem. The line numbers cited are
|
|
13
|
+
* into `SkeletonJson.ts` on branch 4.3; the field-by-field survey is in
|
|
14
|
+
* `docs/SPEC_COVERAGE.md` part 1-8.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
type Json = Record<string, unknown>;
|
|
18
|
+
|
|
19
|
+
function isObj(v: unknown): v is Json {
|
|
20
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How many value channels each timeline carries. A curve array holds exactly
|
|
25
|
+
* four numbers PER channel; anything shorter
|
|
26
|
+
* multiplies `undefined` into the cubic and produces a NaN curve with no error
|
|
27
|
+
* (case 6g). `null` means "this timeline takes no curve at all".
|
|
28
|
+
*/
|
|
29
|
+
const BONE_CHANNELS: Record<string, number | null> = {
|
|
30
|
+
rotate: 1,
|
|
31
|
+
translate: 2,
|
|
32
|
+
translatex: 1,
|
|
33
|
+
translatey: 1,
|
|
34
|
+
scale: 2,
|
|
35
|
+
scalex: 1,
|
|
36
|
+
scaley: 1,
|
|
37
|
+
shear: 2,
|
|
38
|
+
shearx: 1,
|
|
39
|
+
sheary: 1,
|
|
40
|
+
inherit: null,
|
|
41
|
+
};
|
|
42
|
+
const SLOT_CHANNELS: Record<string, number | null> = {
|
|
43
|
+
attachment: null,
|
|
44
|
+
rgba: 4,
|
|
45
|
+
rgb: 3,
|
|
46
|
+
alpha: 1,
|
|
47
|
+
rgba2: 7,
|
|
48
|
+
rgb2: 6,
|
|
49
|
+
};
|
|
50
|
+
const ATTACHMENT_CHANNELS: Record<string, number | null> = {
|
|
51
|
+
deform: 1,
|
|
52
|
+
sequence: null,
|
|
53
|
+
};
|
|
54
|
+
const PHYSICS_CHANNELS: Record<string, number | null> = {
|
|
55
|
+
inertia: 1,
|
|
56
|
+
strength: 1,
|
|
57
|
+
damping: 1,
|
|
58
|
+
mass: 1,
|
|
59
|
+
wind: 1,
|
|
60
|
+
gravity: 1,
|
|
61
|
+
mix: 1,
|
|
62
|
+
reset: null,
|
|
63
|
+
};
|
|
64
|
+
const PATH_CHANNELS: Record<string, number | null> = {
|
|
65
|
+
position: 1,
|
|
66
|
+
spacing: 1,
|
|
67
|
+
mix: 3,
|
|
68
|
+
};
|
|
69
|
+
const SLIDER_CHANNELS: Record<string, number | null> = {
|
|
70
|
+
time: 1,
|
|
71
|
+
mix: 1,
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* `ik` and `transform` are ONE timeline per constraint with no sub-name — the
|
|
75
|
+
* group maps a constraint name straight to a key array. There is no name in the
|
|
76
|
+
* file to look up, so the walker passes the group's own name and these tables
|
|
77
|
+
* hold that single entry.
|
|
78
|
+
*/
|
|
79
|
+
const IK_CHANNELS: Record<string, number | null> = { ik: 2 };
|
|
80
|
+
const TRANSFORM_CHANNELS: Record<string, number | null> = { transform: 6 };
|
|
81
|
+
/** Whole-animation timelines. None of the three can carry a curve at all. */
|
|
82
|
+
const DRAW_ORDER_CHANNELS: Record<string, number | null> = { drawOrder: null };
|
|
83
|
+
const DRAW_ORDER_FOLDER_CHANNELS: Record<string, number | null> = { drawOrderFolder: null };
|
|
84
|
+
const EVENT_CHANNELS: Record<string, number | null> = { events: null };
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* How far past an animation's declared `duration` a key time may land: one step
|
|
88
|
+
* of the grid every key time is rounded onto.
|
|
89
|
+
*
|
|
90
|
+
* The compiler emits key times through `r6`, so 1e-6 s is the finest distinction
|
|
91
|
+
* a key can make and half a step is the most a *correct* key can miss its target
|
|
92
|
+
* by. A key the author put exactly ON a duration of 68/12 s emits as 5.666667 —
|
|
93
|
+
* 3.3e-7 s late, and legal. Anything a whole step further was authored past the
|
|
94
|
+
* end, not rounded onto it.
|
|
95
|
+
*
|
|
96
|
+
* ⚠️ `FRAME` (1/60 s) is the wrong tolerance for this, which is why the constant
|
|
97
|
+
* is separate rather than reused: 1/60 s answers "is the DECLARED DURATION
|
|
98
|
+
* wrong?", it is 16,667 times wider than this, and it hid the defect that put
|
|
99
|
+
* this here. Rung 6 rounded key times to 4 dp in its authoring tooling, so a
|
|
100
|
+
* one-frame attachment reveal landed 3.4e-5 s past a 68/12 s duration — 34 steps
|
|
101
|
+
* past this line but 1/500 of FRAME, with another track already sitting on the
|
|
102
|
+
* declared duration, so the compiler's Rule 4 and the validator's A09 both
|
|
103
|
+
* compared the animation's max key time and agreed. The reveal never fired
|
|
104
|
+
* (issue #54).
|
|
105
|
+
*
|
|
106
|
+
* It lives here, beside the timeline catalogue, for the reason the catalogue
|
|
107
|
+
* does: `compile.ts` refuses on it and `validate.ts` re-checks the emitted file
|
|
108
|
+
* against it, and a second copy of the number is a second copy that drifts.
|
|
109
|
+
*/
|
|
110
|
+
export const KEY_TIME_EPSILON = 1e-6;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Every timeline group `readAnimation` reads (SPEC_COVERAGE part 1-8), keyed by
|
|
114
|
+
* the walker's `kind`. A05 selects its channel table from here, so a group that
|
|
115
|
+
* is missing from this map is a group whose curves nobody checks.
|
|
116
|
+
*/
|
|
117
|
+
export const CHANNELS_BY_KIND: Record<TimelineKind, Record<string, number | null>> = {
|
|
118
|
+
bone: BONE_CHANNELS,
|
|
119
|
+
slot: SLOT_CHANNELS,
|
|
120
|
+
ik: IK_CHANNELS,
|
|
121
|
+
transform: TRANSFORM_CHANNELS,
|
|
122
|
+
path: PATH_CHANNELS,
|
|
123
|
+
physics: PHYSICS_CHANNELS,
|
|
124
|
+
slider: SLIDER_CHANNELS,
|
|
125
|
+
attachment: ATTACHMENT_CHANNELS,
|
|
126
|
+
drawOrder: DRAW_ORDER_CHANNELS,
|
|
127
|
+
drawOrderFolder: DRAW_ORDER_FOLDER_CHANNELS,
|
|
128
|
+
event: EVENT_CHANNELS,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** The eleven timeline groups one animation can hold in 4.3. */
|
|
132
|
+
export type TimelineKind =
|
|
133
|
+
| 'bone'
|
|
134
|
+
| 'slot'
|
|
135
|
+
| 'ik'
|
|
136
|
+
| 'transform'
|
|
137
|
+
| 'path'
|
|
138
|
+
| 'physics'
|
|
139
|
+
| 'slider'
|
|
140
|
+
| 'attachment'
|
|
141
|
+
| 'drawOrder'
|
|
142
|
+
| 'drawOrderFolder'
|
|
143
|
+
| 'event';
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Walks every timeline group `readAnimation` reads and hands each timeline to
|
|
148
|
+
* the visitor.
|
|
149
|
+
*
|
|
150
|
+
* ⚠️ This function is the reach of A05 and A12, so a group it does not descend
|
|
151
|
+
* is a group whose curve arrays nobody checks — and a short curve array is the
|
|
152
|
+
* format's nastiest silent failure (`curve[i+3]` is
|
|
153
|
+
* `undefined`, the cubic yields NaN, nothing throws). It used to descend
|
|
154
|
+
* `bones`, `slots`, `physics` and `attachments` only, which left `ik`,
|
|
155
|
+
* `transform`, `path`, `slider`, `drawOrder`, `drawOrderFolder` and `events`
|
|
156
|
+
* completely unexamined. The comment that stood here claimed drawOrder and
|
|
157
|
+
* events were "skipped by design" because they carry no curves — but "carries
|
|
158
|
+
* no curve" is precisely a rule that has to be CHECKED, and the parser ignores
|
|
159
|
+
* a stray `curve` key on those timelines rather than rejecting it.
|
|
160
|
+
*
|
|
161
|
+
* The groups come in four shapes, and the shape is the whole reason this is not
|
|
162
|
+
* one loop (SPEC_COVERAGE part 1-8):
|
|
163
|
+
*
|
|
164
|
+
* group.<target>.<timeline> = keys[] bones, slots, path, physics, slider
|
|
165
|
+
* group.<target> = keys[] ik, transform — one unnamed timeline
|
|
166
|
+
* group = keys[] drawOrder, events — one per animation
|
|
167
|
+
* group = folders[] with .keys[] drawOrderFolder
|
|
168
|
+
*
|
|
169
|
+
* plus `attachments.<skin>.<slot>.<attachment>.<timeline>`. For the shapes with
|
|
170
|
+
* no timeline name in the file, the walker passes the group's own name so that
|
|
171
|
+
* A05's table lookup and its "unchecked timeline" fail-closed branch both keep
|
|
172
|
+
* working unchanged.
|
|
173
|
+
*/
|
|
174
|
+
export function walkTimelines(
|
|
175
|
+
raw: Json | null,
|
|
176
|
+
visit: (path: string, kind: TimelineKind, name: string, keys: unknown[]) => void,
|
|
177
|
+
): void {
|
|
178
|
+
if (!raw || !isObj(raw.animations)) return;
|
|
179
|
+
for (const [animName, anim] of Object.entries(raw.animations as Json)) {
|
|
180
|
+
if (!isObj(anim)) continue;
|
|
181
|
+
|
|
182
|
+
// group.<target>.<timeline> = keys[]
|
|
183
|
+
for (const [group, kind] of [
|
|
184
|
+
['bones', 'bone'],
|
|
185
|
+
['slots', 'slot'],
|
|
186
|
+
['path', 'path'],
|
|
187
|
+
['physics', 'physics'],
|
|
188
|
+
['slider', 'slider'],
|
|
189
|
+
] as const) {
|
|
190
|
+
if (!isObj(anim[group])) continue;
|
|
191
|
+
for (const [targetName, timelines] of Object.entries(anim[group] as Json)) {
|
|
192
|
+
if (!isObj(timelines)) continue;
|
|
193
|
+
for (const [timelineName, keys] of Object.entries(timelines)) {
|
|
194
|
+
if (!Array.isArray(keys)) continue;
|
|
195
|
+
visit(`${animName}.${group}.${targetName}.${timelineName}`, kind, timelineName, keys);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// group.<constraint> = keys[] — the constraint IS the timeline
|
|
201
|
+
for (const [group, kind] of [
|
|
202
|
+
['ik', 'ik'],
|
|
203
|
+
['transform', 'transform'],
|
|
204
|
+
] as const) {
|
|
205
|
+
if (!isObj(anim[group])) continue;
|
|
206
|
+
for (const [targetName, keys] of Object.entries(anim[group] as Json)) {
|
|
207
|
+
if (!Array.isArray(keys)) continue;
|
|
208
|
+
visit(`${animName}.${group}.${targetName}`, kind, group, keys);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// group = keys[] — one timeline for the whole animation
|
|
213
|
+
for (const [group, kind] of [
|
|
214
|
+
['drawOrder', 'drawOrder'],
|
|
215
|
+
['events', 'event'],
|
|
216
|
+
] as const) {
|
|
217
|
+
const keys = anim[group];
|
|
218
|
+
if (!Array.isArray(keys)) continue;
|
|
219
|
+
visit(`${animName}.${group}`, kind, group, keys);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// drawOrderFolder = [ { slots: [...], keys: [...] } ] — one timeline per folder
|
|
223
|
+
if (Array.isArray(anim.drawOrderFolder)) {
|
|
224
|
+
(anim.drawOrderFolder as unknown[]).forEach((folder, i) => {
|
|
225
|
+
if (!isObj(folder) || !Array.isArray(folder.keys)) return;
|
|
226
|
+
visit(`${animName}.drawOrderFolder[${i}]`, 'drawOrderFolder', 'drawOrderFolder', folder.keys);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// attachments.<skin>.<slot>.<attachment>.<timeline>
|
|
231
|
+
if (isObj(anim.attachments)) {
|
|
232
|
+
for (const [skinName, skinMap] of Object.entries(anim.attachments as Json)) {
|
|
233
|
+
if (!isObj(skinMap)) continue;
|
|
234
|
+
for (const [slotName, slotMap] of Object.entries(skinMap)) {
|
|
235
|
+
if (!isObj(slotMap)) continue;
|
|
236
|
+
for (const [attName, attMap] of Object.entries(slotMap)) {
|
|
237
|
+
if (!isObj(attMap)) continue;
|
|
238
|
+
for (const [timelineName, keys] of Object.entries(attMap)) {
|
|
239
|
+
if (!Array.isArray(keys)) continue;
|
|
240
|
+
visit(
|
|
241
|
+
`${animName}.attachments.${skinName}.${slotName}.${attName}.${timelineName}`,
|
|
242
|
+
'attachment',
|
|
243
|
+
timelineName,
|
|
244
|
+
keys,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
package/src/transform.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup-pose world transforms, and their inverses.
|
|
3
|
+
*
|
|
4
|
+
* The mesh tier used to get away with "bind = world - bone origin", and the
|
|
5
|
+
* compiler asserted the precondition it needed: every bone translation-only. The
|
|
6
|
+
* joint archetype breaks that on purpose — `axis` carries the cut's angle and the
|
|
7
|
+
* grips carry their radial facing — so the shortcut has to become a real inverse.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ A rotated parent would NOT have failed loudly under the old shortcut. It
|
|
10
|
+
* would have sheared every weighted vertex at setup and looked like a badly
|
|
11
|
+
* measured polygon. That is why the old code refused rotation instead of ignoring
|
|
12
|
+
* it, and why this file exists rather than a relaxed assertion.
|
|
13
|
+
*
|
|
14
|
+
* The composition mirrors spine-core exactly (`Bone.updateWorldTransform`):
|
|
15
|
+
*
|
|
16
|
+
* la = cos(rot) lb = cos(rot + 90) = -sin(rot)
|
|
17
|
+
* lc = sin(rot) ld = sin(rot + 90) = cos(rot)
|
|
18
|
+
* a = pa*la + pb*lc b = pa*lb + pb*ld
|
|
19
|
+
* c = pc*la + pd*lc d = pc*lb + pd*ld
|
|
20
|
+
* worldX = pa*x + pb*y + parent.worldX
|
|
21
|
+
*
|
|
22
|
+
* Scale and shear are not emitted by rigc, so they are fixed at 1 and 0 here and
|
|
23
|
+
* the reader does not have to wonder which convention this file chose.
|
|
24
|
+
*/
|
|
25
|
+
import type { SpineBone } from './types.ts';
|
|
26
|
+
|
|
27
|
+
export interface BoneTransform {
|
|
28
|
+
a: number;
|
|
29
|
+
b: number;
|
|
30
|
+
c: number;
|
|
31
|
+
d: number;
|
|
32
|
+
worldX: number;
|
|
33
|
+
worldY: number;
|
|
34
|
+
/** World rotation in degrees, CCW, y up. What a region attachment cancels. */
|
|
35
|
+
worldRotation: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class TransformError extends Error {}
|
|
39
|
+
|
|
40
|
+
const DEG = Math.PI / 180;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Crop pixels (y down, origin top-left) -> Spine world (y up, origin at the
|
|
44
|
+
* bottom-left of the crop).
|
|
45
|
+
*
|
|
46
|
+
* This is the whole coordinate contract, and it is also the viewer's: the
|
|
47
|
+
* renderer's root element sits at the BOTTOM-left of the stage because
|
|
48
|
+
* spine-html negates world Y (Spine is Y-up, CSS is Y-down).
|
|
49
|
+
*
|
|
50
|
+
* It lived in `src/archetype.ts` until the archetype tables left the code, which
|
|
51
|
+
* was the wrong home for it anyway — it is not a property of any formation.
|
|
52
|
+
*/
|
|
53
|
+
export function cropToSpineY(cropY: number, cropHeight: number): number {
|
|
54
|
+
return cropHeight - cropY;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Round to 6 decimals and never emit "-0" (byte-stable output). */
|
|
58
|
+
function r6(n: number): number {
|
|
59
|
+
const v = Math.round(n * 1e6) / 1e6;
|
|
60
|
+
return v === 0 ? 0 : v;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* World transform of every bone, in declaration order.
|
|
65
|
+
*
|
|
66
|
+
* Bones must be declared parents-first, which is also what `SkeletonJson`
|
|
67
|
+
* requires (it resolves `parent` by name against the bones already read), so a
|
|
68
|
+
* violation here is a violation there.
|
|
69
|
+
*/
|
|
70
|
+
export function computeWorldTransforms(bones: SpineBone[]): Map<string, BoneTransform> {
|
|
71
|
+
const out = new Map<string, BoneTransform>();
|
|
72
|
+
for (const bone of bones) {
|
|
73
|
+
const rotation = bone.rotation ?? 0;
|
|
74
|
+
const cos = Math.cos(rotation * DEG);
|
|
75
|
+
const sin = Math.sin(rotation * DEG);
|
|
76
|
+
const la = cos;
|
|
77
|
+
const lb = -sin;
|
|
78
|
+
const lc = sin;
|
|
79
|
+
const ld = cos;
|
|
80
|
+
const x = bone.x ?? 0;
|
|
81
|
+
const y = bone.y ?? 0;
|
|
82
|
+
if (!bone.parent) {
|
|
83
|
+
out.set(bone.name, { a: la, b: lb, c: lc, d: ld, worldX: x, worldY: y, worldRotation: rotation });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const p = out.get(bone.parent);
|
|
87
|
+
if (!p) throw new TransformError(`bone "${bone.name}" names parent "${bone.parent}", which is not declared before it`);
|
|
88
|
+
const a = p.a * la + p.b * lc;
|
|
89
|
+
const b = p.a * lb + p.b * ld;
|
|
90
|
+
const c = p.c * la + p.d * lc;
|
|
91
|
+
const d = p.c * lb + p.d * ld;
|
|
92
|
+
out.set(bone.name, {
|
|
93
|
+
a,
|
|
94
|
+
b,
|
|
95
|
+
c,
|
|
96
|
+
d,
|
|
97
|
+
worldX: p.a * x + p.b * y + p.worldX,
|
|
98
|
+
worldY: p.c * x + p.d * y + p.worldY,
|
|
99
|
+
worldRotation: (Math.atan2(c, a) / DEG + 360) % 360,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** World point -> the bone's local space. Used for bind coordinates and offsets. */
|
|
106
|
+
export function toBoneLocal(m: BoneTransform, worldX: number, worldY: number): [number, number] {
|
|
107
|
+
const det = m.a * m.d - m.b * m.c;
|
|
108
|
+
if (!Number.isFinite(det) || Math.abs(det) < 1e-9) {
|
|
109
|
+
throw new TransformError(`bone transform is singular (det ${det}); a zero-scale bone cannot hold bind coordinates`);
|
|
110
|
+
}
|
|
111
|
+
const px = worldX - m.worldX;
|
|
112
|
+
const py = worldY - m.worldY;
|
|
113
|
+
return [r6((px * m.d - py * m.b) / det), r6((py * m.a - px * m.c) / det)];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Normalise degrees into (-180, 180], which is how an editor shows a rotation. */
|
|
117
|
+
export function normaliseDegrees(deg: number): number {
|
|
118
|
+
let v = ((deg % 360) + 360) % 360;
|
|
119
|
+
if (v > 180) v -= 360;
|
|
120
|
+
return r6(v);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Screen degrees (y down, the convention the manifest and the art share) ->
|
|
125
|
+
* Spine degrees (y up, CCW). One negation, stated once, so no other file has to
|
|
126
|
+
* remember which way the y flip goes.
|
|
127
|
+
*/
|
|
128
|
+
export function screenToSpineDegrees(screenDeg: number): number {
|
|
129
|
+
return normaliseDegrees(-screenDeg);
|
|
130
|
+
}
|