spine-rigc 0.22.2 → 0.23.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 +64 -1
- package/cli.ts +205 -11
- package/docs/AUTHORING.md +357 -54
- package/docs/INGEST.md +185 -41
- package/docs/SPEC_COVERAGE.md +13 -1
- package/package.json +1 -1
- package/skills/ingest/SKILL.md +33 -11
- package/src/atlas.ts +57 -13
- package/src/check.ts +83 -1
- package/src/compile.ts +284 -58
- package/src/diff.ts +125 -2
- package/src/ingest.ts +1078 -0
- package/src/render.ts +104 -10
- package/src/rig.ts +92 -8
- package/src/types.ts +39 -7
- package/src/validate.ts +205 -27
- package/tools/editor_roundtrip.ts +172 -21
package/src/ingest.ts
ADDED
|
@@ -0,0 +1,1078 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ingest — Spine 4.3 skeleton JSON back into a rig spec and a motion spec.
|
|
3
|
+
*
|
|
4
|
+
* ## What this is, and what makes it checkable
|
|
5
|
+
*
|
|
6
|
+
* `build` turns two spec files into a skeleton. This turns a skeleton back into
|
|
7
|
+
* two spec files, so that the contract is an **equality against the file it was
|
|
8
|
+
* read from**: `build(ingest(A)) === A`, byte for byte on `skeleton.json`. Every
|
|
9
|
+
* other gate in this repository compares rigc to rigc — the compiler against the
|
|
10
|
+
* validator, one compile against a second (`A18`), the emitter against its own
|
|
11
|
+
* assertions. This one compares rigc's output against an input rigc did not
|
|
12
|
+
* write, which is the only reference of that kind the tree has.
|
|
13
|
+
*
|
|
14
|
+
* ⇒ So every function below is an **inversion of one named function in
|
|
15
|
+
* [`compile.ts`](compile.ts)**, and each says which. That citation is what makes
|
|
16
|
+
* the module reviewable: a reader checks the pair, not the prose.
|
|
17
|
+
*
|
|
18
|
+
* ## The rule it is held to
|
|
19
|
+
*
|
|
20
|
+
* 🔒 **A decompiler never invents a value the skeleton does not carry.** It is
|
|
21
|
+
* CLAUDE.md's *"the compiler never invents a value that is not in the spec"*,
|
|
22
|
+
* mirrored — and the mirror is where a decompiler's defects live, because a
|
|
23
|
+
* plausible guess here produces a spec that compiles, gates green and says
|
|
24
|
+
* something nobody wrote. Where the skeleton cannot answer, this records a
|
|
25
|
+
* **finding** with a code and writes nothing: `findings` is the product, not a
|
|
26
|
+
* log. Exactly two values are not in a skeleton at all (the stage and an
|
|
27
|
+
* animation's duration) and both are `judgement` findings; every construct the
|
|
28
|
+
* spec format cannot hold is a `blocker`; everything rigc re-derives rather than
|
|
29
|
+
* carries is `lossy`.
|
|
30
|
+
*
|
|
31
|
+
* ## What it does not read
|
|
32
|
+
*
|
|
33
|
+
* Skeleton JSON, and nothing else. Not the atlas, not a `.spine` project, not a
|
|
34
|
+
* binary `.skel`, not the art. A rig spec's texture side is therefore the
|
|
35
|
+
* caller's (`IngestOptions.art`) and is stated as such.
|
|
36
|
+
*
|
|
37
|
+
* ## Purity
|
|
38
|
+
*
|
|
39
|
+
* No clock, no randomness, no filesystem, no network, and no `spine-core` — the
|
|
40
|
+
* three files allowed to link the runtime are named in CLAUDE.md and this is not
|
|
41
|
+
* one of them (`CUR07` refuses a fourth). The provenance `note` therefore carries
|
|
42
|
+
* a version the caller passes in and **no timestamp**, because a timestamp would
|
|
43
|
+
* break `A18_DETERMINISTIC_EMIT` the first time anybody rebuilt from an ingested
|
|
44
|
+
* spec.
|
|
45
|
+
*/
|
|
46
|
+
import { SPINE_VERSION } from './compile.ts';
|
|
47
|
+
import { MOTION_SPEC_VERSION, parseMotionSpec } from './motion.ts';
|
|
48
|
+
import { parseRigSpec, RIG_KEYS, RIG_SPEC_VERSION, type RigSpec } from './rig.ts';
|
|
49
|
+
import type { MotionSpec } from './types.ts';
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// findings
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What a finding is about, which is also what the caller's exit code turns on.
|
|
57
|
+
*
|
|
58
|
+
* - `blocker` — the spec format cannot say this, so the rebuilt skeleton will
|
|
59
|
+
* NOT be the one that was read. Non-zero exit.
|
|
60
|
+
* - `judgement` — the skeleton does not carry it and somebody has to decide.
|
|
61
|
+
* There are exactly two: the stage, and an animation's duration.
|
|
62
|
+
* - `lossy` — the skeleton carries it and rigc re-derives it rather than taking
|
|
63
|
+
* it, which is correct and is said out loud (`lengths`, the `spine` version).
|
|
64
|
+
*/
|
|
65
|
+
export type IngestFindingKind = 'blocker' | 'judgement' | 'lossy';
|
|
66
|
+
|
|
67
|
+
export interface IngestFinding {
|
|
68
|
+
/** Stable code, so a table can count them and a doc can name one. */
|
|
69
|
+
code: string;
|
|
70
|
+
/** The object this is about, named the way a validator failure names one. */
|
|
71
|
+
where: string;
|
|
72
|
+
/** One sentence: what was found, and what it means for the rebuild. */
|
|
73
|
+
detail: string;
|
|
74
|
+
kind: IngestFindingKind;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The stage a skeleton does not carry. See `NO_STAGE` below. */
|
|
78
|
+
export interface IngestStage {
|
|
79
|
+
x: number;
|
|
80
|
+
y: number;
|
|
81
|
+
width: number;
|
|
82
|
+
height: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface IngestOptions {
|
|
86
|
+
/** The rig spec's `name`, which the motion spec's `archetype` must equal. */
|
|
87
|
+
name: string;
|
|
88
|
+
/**
|
|
89
|
+
* How the rebuilt spec gets at the art.
|
|
90
|
+
*
|
|
91
|
+
* `loose` names an `image` per attachment, so `build --images <dir>` measures
|
|
92
|
+
* the PNGs; `none` states `width`/`height` only, for a rebuild that resolves
|
|
93
|
+
* through `build --atlas-in <pack>`. The skeleton encodes neither, which is
|
|
94
|
+
* why this is a flag rather than a derivation.
|
|
95
|
+
*/
|
|
96
|
+
art: 'loose' | 'none';
|
|
97
|
+
/** Supplied stage. Used ONLY when the skeleton carries no width/height. */
|
|
98
|
+
stage?: IngestStage;
|
|
99
|
+
/** The source file's basename, for the provenance note. No path: no leak. */
|
|
100
|
+
source: string;
|
|
101
|
+
/** rigc's own version, for the provenance note. Passed in — `src/` reads no files. */
|
|
102
|
+
version: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface IngestResult {
|
|
106
|
+
rig: RigSpec;
|
|
107
|
+
motion: MotionSpec;
|
|
108
|
+
findings: IngestFinding[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// JSON narrowing — the input is a file somebody else wrote
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
type JsonObject = Record<string, unknown>;
|
|
116
|
+
|
|
117
|
+
function isObj(v: unknown): v is JsonObject {
|
|
118
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The array at `v`, or an empty one. An absent collection is not a fault here. */
|
|
122
|
+
function arr(v: unknown): unknown[] {
|
|
123
|
+
return Array.isArray(v) ? v : [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The object at `v`, or an empty one. */
|
|
127
|
+
function obj(v: unknown): JsonObject {
|
|
128
|
+
return isObj(v) ? v : {};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** `Object.entries` over the OBJECT-valued entries of `v`, in file order. */
|
|
132
|
+
function objEntries(v: unknown): Array<[string, JsonObject]> {
|
|
133
|
+
return Object.entries(obj(v)).filter((entry): entry is [string, JsonObject] => isObj(entry[1]));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** `Object.entries` over the ARRAY-valued entries of `v`, in file order. */
|
|
137
|
+
function arrEntries(v: unknown): Array<[string, unknown[]]> {
|
|
138
|
+
return Object.entries(obj(v)).filter((entry): entry is [string, unknown[]] => Array.isArray(entry[1]));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The numbers at `v`, or undefined. A mixed array is not a number array. */
|
|
142
|
+
function numbers(v: unknown): number[] | undefined {
|
|
143
|
+
if (!Array.isArray(v)) return undefined;
|
|
144
|
+
return v.every((n) => typeof n === 'number') ? (v as number[]) : undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function nameOf(v: unknown): string {
|
|
148
|
+
return isObj(v) && typeof v.name === 'string' ? v.name : '';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// field tables — DERIVED from the rig spec's own key sets, never retyped
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
//
|
|
155
|
+
// ⭐ `RIG_KEYS` is already the statement of which fields a rig spec holds, and
|
|
156
|
+
// `checkRigSpecKeys` refuses everything outside it. Reading the carry list off
|
|
157
|
+
// that table rather than copying it means a field added to the spec becomes
|
|
158
|
+
// carryable here with no edit, and — the half that matters — a skeleton field
|
|
159
|
+
// that has no rig-spec home is refused BY NAME instead of being dropped in
|
|
160
|
+
// silence. A second list would be two lists that have to agree, which is the
|
|
161
|
+
// defect `RIG_KEYS`'s own comment is about.
|
|
162
|
+
|
|
163
|
+
/** Everything `RIG_KEYS` names for a shape, minus the keys this file handles itself. */
|
|
164
|
+
function carried(shape: keyof typeof RIG_KEYS, ...handled: string[]): string[] {
|
|
165
|
+
return (RIG_KEYS[shape] as readonly string[]).filter((key) => !handled.includes(key));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Bone fields carried verbatim. Inverts `buildBone` in `compile.ts`.
|
|
170
|
+
*
|
|
171
|
+
* `from` is excluded because it is rigc's own: a bone position taken from a
|
|
172
|
+
* manifest anchor, resolved to `x`/`y` at compile time (`cropPointOf`). A
|
|
173
|
+
* skeleton holds the resolved numbers and nothing else, so carrying them as
|
|
174
|
+
* `x`/`y` is the inversion and a `from` here would be an invention.
|
|
175
|
+
*/
|
|
176
|
+
const BONE_FIELDS = carried('RigBone', 'name', 'from');
|
|
177
|
+
|
|
178
|
+
/** Slot fields carried verbatim. Inverts the slot loop in `compile()` step 4. */
|
|
179
|
+
const SLOT_FIELDS = carried('RigSlot', 'name');
|
|
180
|
+
|
|
181
|
+
/** Per constraint type, the fields carried verbatim. Inverts `buildRigConstraint`. */
|
|
182
|
+
const CONSTRAINT_FIELDS: Record<string, string[]> = {
|
|
183
|
+
ik: carried('RigIkConstraint', 'name', 'type'),
|
|
184
|
+
transform: carried('RigTransformConstraint', 'name', 'type'),
|
|
185
|
+
path: carried('RigPathConstraint', 'name', 'type'),
|
|
186
|
+
physics: carried('RigPhysicsConstraint', 'name', 'type'),
|
|
187
|
+
slider: carried('RigSliderConstraint', 'name', 'type'),
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The per-skin member lists (`SkeletonJson` reads them before `attachments`).
|
|
192
|
+
*
|
|
193
|
+
* `RIG_KEYS.RigSkinEntry` is `attachments` plus the five constraint lists plus
|
|
194
|
+
* `bones`; the long form of a skin is exactly those, so the list is that set
|
|
195
|
+
* minus the table itself.
|
|
196
|
+
*/
|
|
197
|
+
const SKIN_LISTS = carried('RigSkinEntry', 'attachments');
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* One value-track channel: the JSON field, and what the PARSER reads where a key
|
|
201
|
+
* omits it.
|
|
202
|
+
*
|
|
203
|
+
* ⚠️ A number is a constant default; a STRING is another field of the same key,
|
|
204
|
+
* which is how `mixY` works (`SkeletonJson:988` — it defaults to that key's own
|
|
205
|
+
* `mixX`, not to 1). The same two-shaped table as `CONSTRAINT_TIMELINES`'s
|
|
206
|
+
* `inheritsFrom`, and for the same reason.
|
|
207
|
+
*/
|
|
208
|
+
type TrackShape = Array<[field: string, dflt: number | string]>;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Bone timeline shapes. Inverts `BONE_TRACKS` + `compileValueTrack`.
|
|
212
|
+
*
|
|
213
|
+
* 🚨 The defaults matter more than they look, and they are not all the same:
|
|
214
|
+
* Spine omits a field that equals the SETUP value, `translate` reads 0 there and
|
|
215
|
+
* `scale` reads 1. A decompiler that filled every omission with 0 would collapse
|
|
216
|
+
* every scale key it read, silently.
|
|
217
|
+
*/
|
|
218
|
+
const BONE_TRACKS: Record<string, TrackShape> = {
|
|
219
|
+
translate: [['x', 0], ['y', 0]],
|
|
220
|
+
translatex: [['value', 0]],
|
|
221
|
+
translatey: [['value', 0]],
|
|
222
|
+
scale: [['x', 1], ['y', 1]],
|
|
223
|
+
scalex: [['value', 1]],
|
|
224
|
+
scaley: [['value', 1]],
|
|
225
|
+
shear: [['x', 0], ['y', 0]],
|
|
226
|
+
shearx: [['value', 0]],
|
|
227
|
+
sheary: [['value', 0]],
|
|
228
|
+
rotate: [['value', 0]],
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/** `reset` carries no value at all — `compileValueTrack`'s zero-field branch. */
|
|
232
|
+
const PHYSICS_TRACKS: Record<string, TrackShape> = { mix: [['value', 1]], reset: [] };
|
|
233
|
+
|
|
234
|
+
/** `mix` is three values in ONE key — `PATH_TRACKS` in `compile.ts`. */
|
|
235
|
+
const PATH_TRACKS: Record<string, TrackShape> = {
|
|
236
|
+
position: [['value', 0]],
|
|
237
|
+
spacing: [['value', 0]],
|
|
238
|
+
mix: [['mixRotate', 1], ['mixX', 1], ['mixY', 'mixX']],
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/** ⚠️ `time`'s per-key default is **1**, not 0 (`:1121`). Copied, not assumed. */
|
|
242
|
+
const SLIDER_TRACKS: Record<string, TrackShape> = { time: [['value', 1]], mix: [['value', 1]] };
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The `ik` and `transform` key fields and the value the PARSER reads where a key
|
|
246
|
+
* omits one — `CONSTRAINT_TIMELINES` in `compile.ts`, channels then flags.
|
|
247
|
+
*
|
|
248
|
+
* ⚠️ `mixY` is the one field whose default is not a constant: it is the same
|
|
249
|
+
* key's own `mixX` (`SkeletonJson:988`), which is why it is spelled here as a
|
|
250
|
+
* field name rather than a number.
|
|
251
|
+
*/
|
|
252
|
+
const IK_KEY_DEFAULTS: Record<string, number | boolean> = {
|
|
253
|
+
mix: 1,
|
|
254
|
+
softness: 0,
|
|
255
|
+
bendPositive: true,
|
|
256
|
+
compress: false,
|
|
257
|
+
stretch: false,
|
|
258
|
+
};
|
|
259
|
+
const TRANSFORM_KEY_DEFAULTS: Record<string, number | boolean | string> = {
|
|
260
|
+
mixRotate: 1,
|
|
261
|
+
mixX: 1,
|
|
262
|
+
mixY: 'mixX',
|
|
263
|
+
mixScaleX: 1,
|
|
264
|
+
mixScaleY: 1,
|
|
265
|
+
mixShearY: 1,
|
|
266
|
+
};
|
|
267
|
+
/** The three ik booleans, which `compileConstraintTrack` stamps from the rig. */
|
|
268
|
+
const IK_FLAGS = ['bendPositive', 'compress', 'stretch'];
|
|
269
|
+
|
|
270
|
+
/** The animation groups `readAnimation` reads. Anything else is a blocker. */
|
|
271
|
+
const ANIMATION_GROUPS = ['bones', 'slots', 'ik', 'transform', 'path', 'physics', 'slider', 'attachments', 'drawOrder', 'events'];
|
|
272
|
+
|
|
273
|
+
/** The header fields rigc writes that no rig spec field holds. */
|
|
274
|
+
const HEADER_REDERIVED = ['spine'];
|
|
275
|
+
|
|
276
|
+
/** The attachment types this module inverts. Everything else is refused by name. */
|
|
277
|
+
const ATTACHMENT_TYPES = ['region', 'mesh', 'boundingbox', 'clipping', 'path'];
|
|
278
|
+
|
|
279
|
+
/** The two slot timelines the motion spec carries (`compileTrack`'s two branches). */
|
|
280
|
+
const SLOT_TRACKS = ['rgba', 'attachment'];
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Everything this module has a branch for, as the branches themselves state it.
|
|
284
|
+
*
|
|
285
|
+
* ⭐ It exists so that a gate can ask the question a suite cannot answer from a
|
|
286
|
+
* list somebody typed: **is every construct `ingest` claims to carry actually
|
|
287
|
+
* exercised by a rig somebody builds?** A decompiler branch no rig reaches is a
|
|
288
|
+
* branch nobody has seen work, which is this repository's own definition of not
|
|
289
|
+
* a gate — and the vocabulary has to come from here, because a second copy in
|
|
290
|
+
* `selftest.ts` would go stale in exactly the direction that hides the hole.
|
|
291
|
+
*/
|
|
292
|
+
export const INGEST_VOCABULARY = {
|
|
293
|
+
attachments: ATTACHMENT_TYPES,
|
|
294
|
+
constraints: Object.keys(CONSTRAINT_FIELDS),
|
|
295
|
+
boneTracks: Object.keys(BONE_TRACKS),
|
|
296
|
+
slotTracks: SLOT_TRACKS,
|
|
297
|
+
path: Object.keys(PATH_TRACKS),
|
|
298
|
+
physics: Object.keys(PHYSICS_TRACKS),
|
|
299
|
+
slider: Object.keys(SLIDER_TRACKS),
|
|
300
|
+
animationGroups: ANIMATION_GROUPS,
|
|
301
|
+
} as const satisfies Record<string, readonly string[]>;
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// the inversions
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Spine's flat weight run back into one `{bone, x, y, weight}` list per vertex.
|
|
309
|
+
*
|
|
310
|
+
* 🔒 Inverts `encodeNamedWeights`, and the inversion is **by name** for exactly
|
|
311
|
+
* the reason that function encodes by name: the run holds positions in the
|
|
312
|
+
* EMITTED bone array, a list no spec writes, so a decompiled `vertices` run
|
|
313
|
+
* would rebind every vertex the moment a bone moved in the array (issue #45).
|
|
314
|
+
* The names are the join key on both sides.
|
|
315
|
+
*/
|
|
316
|
+
function decodeWeights(vertices: readonly number[], boneNames: readonly string[]): Array<Array<Record<string, unknown>>> {
|
|
317
|
+
const out: Array<Array<Record<string, unknown>>> = [];
|
|
318
|
+
let i = 0;
|
|
319
|
+
while (i < vertices.length) {
|
|
320
|
+
const count = vertices[i++];
|
|
321
|
+
const vertex: Array<Record<string, unknown>> = [];
|
|
322
|
+
for (let k = 0; k < count; k++) {
|
|
323
|
+
vertex.push({ bone: boneNames[vertices[i]], x: vertices[i + 1], y: vertices[i + 2], weight: vertices[i + 3] });
|
|
324
|
+
i += 4;
|
|
325
|
+
}
|
|
326
|
+
out.push(vertex);
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* `"rrggbbaa"` back to `[r, g, b, a]` in 0..1. Inverts `rgbaHex`.
|
|
333
|
+
*
|
|
334
|
+
* ⚠️ One byte per channel is all the file holds, so this is exact in the only
|
|
335
|
+
* direction that matters: the rebuild quantises the same floats to the same
|
|
336
|
+
* bytes. It is not a recovery of whatever the original author typed.
|
|
337
|
+
*/
|
|
338
|
+
function hexToRgba(hex: string): number[] {
|
|
339
|
+
return [0, 2, 4, 6].map((i) => Number.parseInt(hex.slice(i, i + 2), 16) / 255);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Which placeholders more than one skin fills, per slot.
|
|
344
|
+
*
|
|
345
|
+
* 🔒 Inverts `contestedPlaceholders` + `nameSkinAttachment`: rigc writes an
|
|
346
|
+
* attachment `name` exactly where a placeholder is contested, and the rig spec
|
|
347
|
+
* has no field for one. So the name is DROPPED and re-derived — which is right
|
|
348
|
+
* where the contest survives the round trip, and a loss anywhere else. This is
|
|
349
|
+
* what lets that difference be reported rather than assumed.
|
|
350
|
+
*/
|
|
351
|
+
function contestedPlaceholders(skins: readonly unknown[]): Map<string, Set<string>> {
|
|
352
|
+
const fillers = new Map<string, Map<string, number>>();
|
|
353
|
+
for (const skin of skins) {
|
|
354
|
+
for (const [slot, placeholders] of objEntries(obj(skin).attachments)) {
|
|
355
|
+
const perSlot = fillers.get(slot) ?? new Map<string, number>();
|
|
356
|
+
for (const placeholder of Object.keys(placeholders)) {
|
|
357
|
+
perSlot.set(placeholder, (perSlot.get(placeholder) ?? 0) + 1);
|
|
358
|
+
}
|
|
359
|
+
fillers.set(slot, perSlot);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const contested = new Map<string, Set<string>>();
|
|
363
|
+
for (const [slot, perSlot] of fillers) {
|
|
364
|
+
const shared = new Set([...perSlot].filter(([, count]) => count > 1).map(([placeholder]) => placeholder));
|
|
365
|
+
if (shared.size) contested.set(slot, shared);
|
|
366
|
+
}
|
|
367
|
+
return contested;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Read a skeleton, write the two specs that rebuild it.
|
|
372
|
+
*
|
|
373
|
+
* Pure: the same skeleton and the same options give the same specs, every time.
|
|
374
|
+
* The two returned specs have been through `parseRigSpec` and `parseMotionSpec`
|
|
375
|
+
* before they leave — a decompiler that hands back something the compiler's own
|
|
376
|
+
* parser would refuse has produced a file nobody can use, and saying so here
|
|
377
|
+
* names the decompiler instead of leaving `build` to name the file.
|
|
378
|
+
*/
|
|
379
|
+
export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
|
|
380
|
+
const findings: IngestFinding[] = [];
|
|
381
|
+
const note = (kind: IngestFindingKind, code: string, where: string, detail: string): void => {
|
|
382
|
+
findings.push({ code, where, detail, kind });
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const root = obj(skeleton);
|
|
386
|
+
const boneNames: string[] = arr(root.bones).map(nameOf);
|
|
387
|
+
|
|
388
|
+
// -- header ---------------------------------------------------------------
|
|
389
|
+
// 🚨 THE SEAM. One function decides the rig spec's `skeleton` block, and the
|
|
390
|
+
// stage is the only value in this whole module that a skeleton cannot answer
|
|
391
|
+
// for. Issue #578 is landing a way for a rig spec to SAY that a skeleton
|
|
392
|
+
// declares no stage; when it does, this is the one place that changes.
|
|
393
|
+
const rigHeader = ingestHeader(obj(root.skeleton), opts, note);
|
|
394
|
+
|
|
395
|
+
// -- bones ----------------------------------------------------------------
|
|
396
|
+
// Inverts `buildBone`, which copies every declared field and omits the rest.
|
|
397
|
+
const bones = arr(root.bones).map((raw) => {
|
|
398
|
+
const bone = obj(raw);
|
|
399
|
+
const out: JsonObject = { name: bone.name };
|
|
400
|
+
for (const field of BONE_FIELDS) if (bone[field] !== undefined) out[field] = bone[field];
|
|
401
|
+
for (const key of Object.keys(bone)) {
|
|
402
|
+
if (key === 'name' || BONE_FIELDS.includes(key)) continue;
|
|
403
|
+
note('blocker', 'BONE_FIELD', `bone "${nameOf(bone)}"`, `field "${key}" has no rig-spec field, so it is dropped`);
|
|
404
|
+
}
|
|
405
|
+
return out;
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// -- slots ----------------------------------------------------------------
|
|
409
|
+
// A slot some skin fills but that shows nothing in the setup pose carries NO
|
|
410
|
+
// `attachment` field, and `build` refuses a filled slot with no setup pose
|
|
411
|
+
// ("the compiler will not guess one"). The skeleton does state it: an absent
|
|
412
|
+
// `attachment` on a filled slot means "show nothing", which the rig spec
|
|
413
|
+
// spells `null`. Transcribing an absence is not inventing a value.
|
|
414
|
+
const filled = new Set<string>();
|
|
415
|
+
for (const skin of arr(root.skins)) {
|
|
416
|
+
for (const [slot, placeholders] of objEntries(obj(skin).attachments)) {
|
|
417
|
+
if (Object.keys(placeholders).length) filled.add(slot);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
const slots = arr(root.slots).map((raw) => {
|
|
421
|
+
const slot = obj(raw);
|
|
422
|
+
const out: JsonObject = { name: slot.name };
|
|
423
|
+
for (const field of SLOT_FIELDS) if (slot[field] !== undefined) out[field] = slot[field];
|
|
424
|
+
if (out.attachment === undefined && filled.has(nameOf(slot))) out.attachment = null;
|
|
425
|
+
for (const key of Object.keys(slot)) {
|
|
426
|
+
if (key === 'name' || SLOT_FIELDS.includes(key)) continue;
|
|
427
|
+
note('blocker', 'SLOT_FIELD', `slot "${nameOf(slot)}"`, `field "${key}" has no rig-spec field, so it is dropped`);
|
|
428
|
+
}
|
|
429
|
+
return out;
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// -- skins and attachments ------------------------------------------------
|
|
433
|
+
const contested = contestedPlaceholders(arr(root.skins));
|
|
434
|
+
const skins: JsonObject = {};
|
|
435
|
+
for (const skin of arr(root.skins)) {
|
|
436
|
+
const entry = obj(skin);
|
|
437
|
+
const table: JsonObject = {};
|
|
438
|
+
for (const [slot, placeholders] of objEntries(entry.attachments)) {
|
|
439
|
+
const perSlot: JsonObject = {};
|
|
440
|
+
for (const [placeholder, att] of Object.entries(placeholders)) {
|
|
441
|
+
perSlot[placeholder] = ingestAttachment(obj(att), placeholder, boneNames, opts, note, {
|
|
442
|
+
where: `skin "${nameOf(entry)}" slot "${slot}" attachment "${placeholder}"`,
|
|
443
|
+
contested: contested.get(slot)?.has(placeholder) === true,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
table[slot] = perSlot;
|
|
447
|
+
}
|
|
448
|
+
const lists: JsonObject = {};
|
|
449
|
+
let anyList = false;
|
|
450
|
+
for (const list of SKIN_LISTS) {
|
|
451
|
+
if (entry[list] !== undefined) {
|
|
452
|
+
lists[list] = entry[list];
|
|
453
|
+
anyList = true;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
// The long form only where the skin activates something; otherwise the short
|
|
457
|
+
// form, which is what every rig in this tree writes and what `splitRigSkin`
|
|
458
|
+
// reads back as the bare attachment table.
|
|
459
|
+
skins[nameOf(entry)] = anyList ? { ...lists, attachments: table } : table;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// -- constraints ----------------------------------------------------------
|
|
463
|
+
// Inverts `buildRigConstraint`. 4.3 puts every type in ONE array and branches
|
|
464
|
+
// on `type`, so an unknown `type` is refused here for the same reason the
|
|
465
|
+
// parser's silence about it is assertion A01: it would simply vanish.
|
|
466
|
+
const constraints: JsonObject[] = [];
|
|
467
|
+
for (const raw of arr(root.constraints)) {
|
|
468
|
+
const constraint = obj(raw);
|
|
469
|
+
const type = typeof constraint.type === 'string' ? constraint.type : '';
|
|
470
|
+
const fields = CONSTRAINT_FIELDS[type];
|
|
471
|
+
const who = `constraint "${nameOf(constraint)}"`;
|
|
472
|
+
if (fields === undefined) {
|
|
473
|
+
note('blocker', 'CONSTRAINT_TYPE', who, `type ${JSON.stringify(constraint.type)} is not one of ${Object.keys(CONSTRAINT_FIELDS).join(', ')}`);
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
const out: JsonObject = { name: constraint.name, type };
|
|
477
|
+
for (const field of fields) if (constraint[field] !== undefined) out[field] = constraint[field];
|
|
478
|
+
for (const key of Object.keys(constraint)) {
|
|
479
|
+
if (key === 'name' || key === 'type' || fields.includes(key)) continue;
|
|
480
|
+
note('blocker', 'CONSTRAINT_FIELD', `${who} (${type})`, `field "${key}" has no rig-spec field, so it is dropped`);
|
|
481
|
+
}
|
|
482
|
+
constraints.push(out);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// -- animations -----------------------------------------------------------
|
|
486
|
+
const animations: JsonObject = {};
|
|
487
|
+
for (const [animName, raw] of objEntries(root.animations)) {
|
|
488
|
+
animations[animName] = ingestAnimation(animName, raw, root, note);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// -- assemble -------------------------------------------------------------
|
|
492
|
+
const rig: JsonObject = {
|
|
493
|
+
spec: RIG_SPEC_VERSION,
|
|
494
|
+
name: opts.name,
|
|
495
|
+
note: provenanceNote(opts, 'rig'),
|
|
496
|
+
};
|
|
497
|
+
if (Object.keys(rigHeader).length) rig.skeleton = rigHeader;
|
|
498
|
+
rig.bones = bones;
|
|
499
|
+
rig.slots = slots;
|
|
500
|
+
if (Object.keys(skins).length) rig.skins = skins;
|
|
501
|
+
if (constraints.length) rig.constraints = constraints;
|
|
502
|
+
if (isObj(root.events)) rig.events = { ...root.events };
|
|
503
|
+
// `invariants` is deliberately absent. A skeleton states no invariant, and
|
|
504
|
+
// INGEST §2.1 already says what to do about that: leave the block out, because
|
|
505
|
+
// an assertion with nothing to measure reports SKIP and never a pass. Writing
|
|
506
|
+
// an invariant here would be certifying a rig nobody measured.
|
|
507
|
+
|
|
508
|
+
const motion: JsonObject = {
|
|
509
|
+
spec: MOTION_SPEC_VERSION,
|
|
510
|
+
archetype: opts.name,
|
|
511
|
+
cut: opts.name,
|
|
512
|
+
note: provenanceNote(opts, 'motion'),
|
|
513
|
+
// Empty on purpose: every curve below is written as a RAW `curve` array.
|
|
514
|
+
// A named easing says "this shape, wherever it is used" and an export carries
|
|
515
|
+
// a different bezier per key per channel, so there is no named easing to
|
|
516
|
+
// recognise — only a shape to copy. `easingCurve`'s output is what a raw
|
|
517
|
+
// curve holds, which is why the rebuild is byte-identical either way.
|
|
518
|
+
easings: {},
|
|
519
|
+
animations,
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
return {
|
|
523
|
+
// 🔒 Through the tree's own parsers before they leave. `parseRigSpec` and
|
|
524
|
+
// `parseMotionSpec` are what `build` reads these files with, so a spec this
|
|
525
|
+
// module could produce and `build` would refuse is named here, at the
|
|
526
|
+
// decompiler, rather than three commands later at the file.
|
|
527
|
+
rig: parseRigSpec(rig, `ingest(${opts.source}): rig spec`),
|
|
528
|
+
motion: parseMotionSpec(motion, `ingest(${opts.source}): motion spec`),
|
|
529
|
+
findings,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
type Note = (kind: IngestFindingKind, code: string, where: string, detail: string) => void;
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* The rig spec's `skeleton` block — and the one judgement in this module.
|
|
537
|
+
*
|
|
538
|
+
* 🚨 **The stage is not in a skeleton JSON that an editor wrote.** rigc always
|
|
539
|
+
* emits `x`/`y`/`width`/`height`, so a rigc build round-trips with nothing to
|
|
540
|
+
* decide; an editor export's `skeleton` block is `hash`, `spine`, `images`,
|
|
541
|
+
* `audio` and no box at all. `compile` refuses without one, and there is no
|
|
542
|
+
* derivation: posing the rig gives the ANIMATED extent, which is a different
|
|
543
|
+
* number from the editor's setup box. So with no `--stage` this records a
|
|
544
|
+
* blocker naming the field and writes nothing plausible.
|
|
545
|
+
*
|
|
546
|
+
* ⭐ It is also the judgement that costs nothing to get wrong, which is the worst
|
|
547
|
+
* property a field can have: `diff` has no skeleton-header measure, so a
|
|
548
|
+
* deliberately absurd unit box reads 1.000 on every measure there is.
|
|
549
|
+
*/
|
|
550
|
+
function ingestHeader(head: JsonObject, opts: IngestOptions, note: Note): JsonObject {
|
|
551
|
+
const out: JsonObject = {};
|
|
552
|
+
for (const field of RIG_KEYS.RigSkeletonHeader) if (head[field] !== undefined) out[field] = head[field];
|
|
553
|
+
for (const key of Object.keys(head)) {
|
|
554
|
+
if ((RIG_KEYS.RigSkeletonHeader as readonly string[]).includes(key)) continue;
|
|
555
|
+
if (HEADER_REDERIVED.includes(key)) {
|
|
556
|
+
// Two-sided on purpose: the same fact reads as bookkeeping when the two
|
|
557
|
+
// agree and as a warning when they do not, and a reader needs to be told
|
|
558
|
+
// which — a rebuild of a 4.2 export states 4.3, in one field, silently.
|
|
559
|
+
const same = head[key] === SPINE_VERSION;
|
|
560
|
+
note(
|
|
561
|
+
'lossy',
|
|
562
|
+
'HEADER_REDERIVED',
|
|
563
|
+
`skeleton.${key}`,
|
|
564
|
+
`the source states ${JSON.stringify(head[key])} and the rig spec has no field for it: a rebuild writes the ` +
|
|
565
|
+
`version of the runtime rigc links, ${SPINE_VERSION}` +
|
|
566
|
+
(same ? ', which is the same string, so nothing moves' : ' — so this field WILL change on the rebuild'),
|
|
567
|
+
);
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
note(
|
|
571
|
+
'lossy',
|
|
572
|
+
'HEADER_BOOKKEEPING',
|
|
573
|
+
`skeleton.${key}`,
|
|
574
|
+
`the editor writes "${key}" and the rig spec has no field for it; it is dropped and nothing reads it back`,
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
if (out.width !== undefined && out.height !== undefined) return out;
|
|
578
|
+
if (opts.stage === undefined) {
|
|
579
|
+
note(
|
|
580
|
+
'blocker',
|
|
581
|
+
'NO_STAGE',
|
|
582
|
+
'skeleton.width/height',
|
|
583
|
+
'the skeleton declares no stage; give --stage x,y,w,h — the value is the caller\'s, not derived. Posing the ' +
|
|
584
|
+
'rig would give the ANIMATED extent, which is a different number from the setup box, so rigc refuses rather ' +
|
|
585
|
+
'than measuring the wrong thing (or state the absence once the spec can)',
|
|
586
|
+
);
|
|
587
|
+
return out;
|
|
588
|
+
}
|
|
589
|
+
Object.assign(out, opts.stage);
|
|
590
|
+
note(
|
|
591
|
+
'judgement',
|
|
592
|
+
'NO_STAGE',
|
|
593
|
+
'skeleton.width/height',
|
|
594
|
+
`the skeleton declares no stage and the caller supplied ${opts.stage.x},${opts.stage.y},${opts.stage.width},` +
|
|
595
|
+
`${opts.stage.height}. Nothing measured it: no gate in this tree reads the skeleton header, so a wrong box is ` +
|
|
596
|
+
'green everywhere (or state the absence once the spec can)',
|
|
597
|
+
);
|
|
598
|
+
return out;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* One attachment, by type. Inverts `buildRigAttachment`'s five branches.
|
|
603
|
+
*
|
|
604
|
+
* The types rigc does not emit are refused BY NAME rather than dropped, which is
|
|
605
|
+
* the same reason `buildRigAttachment` refuses them: the parser's own behaviour
|
|
606
|
+
* on a type it does not know is to return null and carry on, so a decompiler
|
|
607
|
+
* that skipped one would hand back a spec that is quietly missing an attachment.
|
|
608
|
+
*/
|
|
609
|
+
function ingestAttachment(
|
|
610
|
+
att: JsonObject,
|
|
611
|
+
placeholder: string,
|
|
612
|
+
boneNames: readonly string[],
|
|
613
|
+
opts: IngestOptions,
|
|
614
|
+
note: Note,
|
|
615
|
+
at: { where: string; contested: boolean },
|
|
616
|
+
): JsonObject {
|
|
617
|
+
// `readAttachment` reads no `type` as `region` (`SkeletonJson:539`), and so
|
|
618
|
+
// does `checkRigSpecKeys`. Both defaults are the parser's, not a guess.
|
|
619
|
+
const type = att.type === undefined ? 'region' : String(att.type);
|
|
620
|
+
const out: JsonObject = {};
|
|
621
|
+
|
|
622
|
+
// `name` is DERIVED by rigc — `composeSkinAttachmentName` writes one exactly
|
|
623
|
+
// where a placeholder is contested — so the rig spec has no field for it and
|
|
624
|
+
// it is dropped. Where the contest survives the round trip the same name comes
|
|
625
|
+
// back; anywhere else it is a real loss and is reported.
|
|
626
|
+
if (att.name !== undefined && !at.contested) {
|
|
627
|
+
note(
|
|
628
|
+
'lossy',
|
|
629
|
+
'ATTACHMENT_NAME',
|
|
630
|
+
at.where,
|
|
631
|
+
`the attachment states name ${JSON.stringify(att.name)} and only ONE skin fills this placeholder, so rigc ` +
|
|
632
|
+
'writes no name on the rebuild — it composes "<skin>/<placeholder>" only for a contested placeholder (#541)',
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** The texture side, which the skeleton does not encode. Inverts `buildRigRegion`'s tail. */
|
|
637
|
+
const carryArt = (): void => {
|
|
638
|
+
if (att.path !== undefined) out.path = att.path;
|
|
639
|
+
// `buildRigRegion` writes `path` when the image basename differs from the
|
|
640
|
+
// placeholder, so naming the image after `path ?? placeholder` reproduces
|
|
641
|
+
// the same `path` decision AND the same atlas region name.
|
|
642
|
+
if (opts.art === 'loose') out.image = `${att.path === undefined ? placeholder : String(att.path)}.png`;
|
|
643
|
+
if (att.width !== undefined) out.width = att.width;
|
|
644
|
+
if (att.height !== undefined) out.height = att.height;
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* The vertex array, as one of the two encodings `readVertices` decides between.
|
|
649
|
+
*
|
|
650
|
+
* Inverts `buildVertexGeometry` / `encodeNamedWeights`: the run is unweighted
|
|
651
|
+
* when it is exactly as long as the coordinate count the attachment declares,
|
|
652
|
+
* and a weight run otherwise. That length comparison is the parser's own.
|
|
653
|
+
*/
|
|
654
|
+
const geometry = (declaredPairs: number | undefined): void => {
|
|
655
|
+
const vertices = numbers(att.vertices);
|
|
656
|
+
if (vertices === undefined) return;
|
|
657
|
+
if (declaredPairs !== undefined && vertices.length === declaredPairs * 2) out.vertices = vertices;
|
|
658
|
+
else out.weights = decodeWeights(vertices, boneNames);
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
const vertexCount = typeof att.vertexCount === 'number' ? att.vertexCount : undefined;
|
|
662
|
+
|
|
663
|
+
if (type === 'region') {
|
|
664
|
+
carryArt();
|
|
665
|
+
for (const field of ['x', 'y', 'rotation', 'scaleX', 'scaleY', 'color']) {
|
|
666
|
+
if (att[field] !== undefined) out[field] = att[field];
|
|
667
|
+
}
|
|
668
|
+
} else if (type === 'mesh') {
|
|
669
|
+
out.type = 'mesh';
|
|
670
|
+
carryArt();
|
|
671
|
+
out.uvs = att.uvs;
|
|
672
|
+
out.triangles = att.triangles;
|
|
673
|
+
geometry(Array.isArray(att.uvs) ? att.uvs.length / 2 : undefined);
|
|
674
|
+
// Carried rather than re-derived: `authoredHullAndEdges` takes an authored
|
|
675
|
+
// pair as written and cross-checks `hull` against the triangles, so stating
|
|
676
|
+
// both keeps the emitted arrays identical instead of equal-by-derivation.
|
|
677
|
+
if (att.hull !== undefined) out.hull = att.hull;
|
|
678
|
+
if (att.edges !== undefined) out.edges = att.edges;
|
|
679
|
+
if (att.color !== undefined) out.color = att.color;
|
|
680
|
+
} else if (type === 'boundingbox' || type === 'clipping') {
|
|
681
|
+
out.type = type;
|
|
682
|
+
out.vertexCount = att.vertexCount;
|
|
683
|
+
geometry(vertexCount);
|
|
684
|
+
if (att.color !== undefined) out.color = att.color;
|
|
685
|
+
if (type === 'clipping') {
|
|
686
|
+
for (const field of ['end', 'convex', 'inverse']) if (att[field] !== undefined) out[field] = att[field];
|
|
687
|
+
}
|
|
688
|
+
} else if (type === 'path') {
|
|
689
|
+
out.type = 'path';
|
|
690
|
+
out.vertexCount = att.vertexCount;
|
|
691
|
+
geometry(vertexCount);
|
|
692
|
+
for (const field of ['closed', 'constantSpeed', 'color']) if (att[field] !== undefined) out[field] = att[field];
|
|
693
|
+
if (att.lengths !== undefined) {
|
|
694
|
+
note(
|
|
695
|
+
'lossy',
|
|
696
|
+
'PATH_LENGTHS',
|
|
697
|
+
at.where,
|
|
698
|
+
'the source states `lengths`; the rig spec refuses an authored one and rigc RE-MEASURES it as ' +
|
|
699
|
+
'`PathConstraint` does (issue #560, `pathCurveLengths`). Dropping it is correct: the field is the ' +
|
|
700
|
+
"runtime's own four-sample forward difference, not an arc length, and a transcribed one would freeze " +
|
|
701
|
+
'whatever produced the source',
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
} else {
|
|
705
|
+
note(
|
|
706
|
+
'blocker',
|
|
707
|
+
`ATTACHMENT_${type.toUpperCase()}`,
|
|
708
|
+
at.where,
|
|
709
|
+
`attachment type ${JSON.stringify(type)} is in the Spine 4.3 format and rigc does not emit it (it emits ` +
|
|
710
|
+
`${ATTACHMENT_TYPES.join(', ')}; point and linkedmesh are deferred — docs/SPEC_COVERAGE.md part 1-6 says ` +
|
|
711
|
+
'what each would carry). The rebuild will not have this attachment',
|
|
712
|
+
);
|
|
713
|
+
return out;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (att.sequence !== undefined) {
|
|
717
|
+
note(
|
|
718
|
+
'blocker',
|
|
719
|
+
'ATTACHMENT_SEQUENCE',
|
|
720
|
+
at.where,
|
|
721
|
+
'the attachment carries a `sequence` block (a numbered image series), which the rig spec cannot say; ' +
|
|
722
|
+
'the rebuild draws the single region this attachment names',
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
return out;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** One animation. Inverts step 5 of `compile()` — the whole timeline half. */
|
|
729
|
+
function ingestAnimation(animName: string, anim: JsonObject, root: JsonObject, note: Note): JsonObject {
|
|
730
|
+
const tracks: JsonObject[] = [];
|
|
731
|
+
let maxT = 0;
|
|
732
|
+
const seeT = (t: number): void => {
|
|
733
|
+
if (t > maxT) maxT = t;
|
|
734
|
+
};
|
|
735
|
+
const timeOf = (key: JsonObject): number => {
|
|
736
|
+
const t = typeof key.time === 'number' ? key.time : 0;
|
|
737
|
+
seeT(t);
|
|
738
|
+
return t;
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* A key's easing, as the motion spec spells it.
|
|
743
|
+
*
|
|
744
|
+
* Inverts `rawCurve`: `"stepped"` is a named easing the compiler passes
|
|
745
|
+
* through, and an array is the absolute (time, value) control points, four per
|
|
746
|
+
* channel, which `curve` takes verbatim. A key with neither is linear.
|
|
747
|
+
*/
|
|
748
|
+
const easing = (key: JsonObject, out: JsonObject): void => {
|
|
749
|
+
if (key.curve === 'stepped') out.ease = 'stepped';
|
|
750
|
+
else if (key.curve !== undefined) out.curve = key.curve;
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* A value track of any of the four families. Inverts `compileValueTrack`.
|
|
755
|
+
*
|
|
756
|
+
* ⚠️ `compileValueTrack` never omits a channel, so a rigc-built key states all
|
|
757
|
+
* of them and this reads them straight back. An EDITOR omits a channel that
|
|
758
|
+
* equals the parser's default, and the spec's `v` is positional — so an
|
|
759
|
+
* omission is filled at that channel's default, which is the value the runtime
|
|
760
|
+
* reads there. Reported once per track, because it is a restatement rather
|
|
761
|
+
* than a copy and a reader should know which.
|
|
762
|
+
*/
|
|
763
|
+
const valueTrack = (target: JsonObject, property: string, keys: readonly unknown[], shape: TrackShape, where: string): void => {
|
|
764
|
+
const fields = shape.map(([field]) => field);
|
|
765
|
+
const out: JsonObject[] = [];
|
|
766
|
+
let restated = 0;
|
|
767
|
+
for (const raw of keys) {
|
|
768
|
+
const key = obj(raw);
|
|
769
|
+
const entry: JsonObject = { t: timeOf(key) };
|
|
770
|
+
// The zero-field branch: `reset` IS the event, so the key carries no value
|
|
771
|
+
// and the spec spells that `null`.
|
|
772
|
+
entry.v =
|
|
773
|
+
shape.length === 0
|
|
774
|
+
? null
|
|
775
|
+
: shape.map(([field, dflt]) => {
|
|
776
|
+
if (key[field] !== undefined) return key[field];
|
|
777
|
+
restated++;
|
|
778
|
+
// A string default names another field of THIS key (`mixY` ->
|
|
779
|
+
// `mixX`); when that one is absent too the chain ends at 1, which
|
|
780
|
+
// is what `ConstraintChannel.dflt` holds for both.
|
|
781
|
+
if (typeof dflt !== 'string') return dflt;
|
|
782
|
+
return key[dflt] === undefined ? 1 : key[dflt];
|
|
783
|
+
});
|
|
784
|
+
easing(key, entry);
|
|
785
|
+
for (const field of Object.keys(key)) {
|
|
786
|
+
if (field === 'time' || field === 'curve' || fields.includes(field)) continue;
|
|
787
|
+
note('blocker', 'TIMELINE_FIELD', where, `key field "${field}" is not part of this timeline's shape`);
|
|
788
|
+
}
|
|
789
|
+
out.push(entry);
|
|
790
|
+
}
|
|
791
|
+
if (restated > 0) {
|
|
792
|
+
note(
|
|
793
|
+
'lossy',
|
|
794
|
+
'TIMELINE_KEY_RESTATED',
|
|
795
|
+
where,
|
|
796
|
+
`${restated} channel value(s) the source omits are written out at the parser's default (${shape
|
|
797
|
+
.map(([field, dflt]) => `${field}=${String(dflt)}`)
|
|
798
|
+
.join(', ')}), because the motion spec's \`v\` is positional — the same values the runtime reads`,
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
tracks.push({ ...target, property, keys: out });
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
/** One family of constraint timelines: `<family>.<constraint>.<timeline>`. */
|
|
805
|
+
const family = (group: 'path' | 'physics' | 'slider', shapes: Record<string, TrackShape>): void => {
|
|
806
|
+
for (const [name, timelines] of objEntries(anim[group])) {
|
|
807
|
+
for (const [property, keys] of arrEntries(timelines)) {
|
|
808
|
+
const shape = shapes[property];
|
|
809
|
+
const where = `animation "${animName}" ${group} "${name}" ${property}`;
|
|
810
|
+
if (shape === undefined) {
|
|
811
|
+
note('blocker', `${group.toUpperCase()}_TIMELINE`, where, `timeline "${property}" is not in the motion spec`);
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
valueTrack({ [group]: name }, property, keys, shape, where);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
for (const [bone, timelines] of objEntries(anim.bones)) {
|
|
820
|
+
for (const [property, keys] of arrEntries(timelines)) {
|
|
821
|
+
const shape = BONE_TRACKS[property];
|
|
822
|
+
const where = `animation "${animName}" bone "${bone}" ${property}`;
|
|
823
|
+
if (shape === undefined) {
|
|
824
|
+
note('blocker', 'BONE_TIMELINE', where, `timeline "${property}" is not in the motion spec`);
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
valueTrack({ bone }, property, keys, shape, where);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
for (const [slot, timelines] of objEntries(anim.slots)) {
|
|
832
|
+
for (const [property, keys] of arrEntries(timelines)) {
|
|
833
|
+
const where = `animation "${animName}" slot "${slot}" ${property}`;
|
|
834
|
+
if (property === 'attachment') {
|
|
835
|
+
// Inverts `compileTrack`'s attachment branch: `{time, name}`, where a
|
|
836
|
+
// null name is "show nothing". Attachment keys are stepped by nature and
|
|
837
|
+
// carry no curve at all.
|
|
838
|
+
tracks.push({
|
|
839
|
+
slot,
|
|
840
|
+
property: 'attachment',
|
|
841
|
+
keys: keys.map((raw) => {
|
|
842
|
+
const key = obj(raw);
|
|
843
|
+
return { t: timeOf(key), v: key.name === undefined ? null : key.name };
|
|
844
|
+
}),
|
|
845
|
+
});
|
|
846
|
+
} else if (property === 'rgba') {
|
|
847
|
+
// Inverts `compileTrack`'s rgba branch, whose key is `{time, color}`.
|
|
848
|
+
tracks.push({
|
|
849
|
+
slot,
|
|
850
|
+
property: 'rgba',
|
|
851
|
+
keys: keys.map((raw) => {
|
|
852
|
+
const key = obj(raw);
|
|
853
|
+
const entry: JsonObject = { t: timeOf(key), v: hexToRgba(String(key.color)) };
|
|
854
|
+
easing(key, entry);
|
|
855
|
+
return entry;
|
|
856
|
+
}),
|
|
857
|
+
});
|
|
858
|
+
} else {
|
|
859
|
+
note(
|
|
860
|
+
'blocker',
|
|
861
|
+
'SLOT_TIMELINE',
|
|
862
|
+
where,
|
|
863
|
+
`timeline "${property}" is in the format and the motion spec has no track for it — a slot track is ` +
|
|
864
|
+
`${SLOT_TRACKS.join(' or ')} and nothing else, so the rebuild plays nothing here`,
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
family('path', PATH_TRACKS);
|
|
871
|
+
family('physics', PHYSICS_TRACKS);
|
|
872
|
+
family('slider', SLIDER_TRACKS);
|
|
873
|
+
|
|
874
|
+
const ik = constraintGroup('ik', animName, anim, root, IK_KEY_DEFAULTS, seeT, note);
|
|
875
|
+
const transform = constraintGroup('transform', animName, anim, root, TRANSFORM_KEY_DEFAULTS, seeT, note);
|
|
876
|
+
|
|
877
|
+
// deform — `attachments.<skin>.<slot>.<attachment>.<timeline>`.
|
|
878
|
+
// Inverts `compileDeformTrack`, whose emitted key is `{time, offset?, vertices?}`
|
|
879
|
+
// and whose `offset` is omitted at 0 (the parser's default).
|
|
880
|
+
const deform: JsonObject[] = [];
|
|
881
|
+
for (const [skinName, perSkin] of objEntries(anim.attachments)) {
|
|
882
|
+
for (const [slot, perSlot] of objEntries(perSkin)) {
|
|
883
|
+
for (const [attachment, timelines] of objEntries(perSlot)) {
|
|
884
|
+
for (const [property, keys] of arrEntries(timelines)) {
|
|
885
|
+
const where = `animation "${animName}" ${skinName}/${slot}/${attachment}`;
|
|
886
|
+
if (property !== 'deform') {
|
|
887
|
+
note('blocker', 'ATTACHMENT_TIMELINE', where, `timeline "${property}" (the motion spec carries \`deform\` only)`);
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
const entry: JsonObject = {
|
|
891
|
+
slot,
|
|
892
|
+
attachment,
|
|
893
|
+
keys: keys.map((raw) => {
|
|
894
|
+
const key = obj(raw);
|
|
895
|
+
const out: JsonObject = { t: timeOf(key) };
|
|
896
|
+
if (key.offset !== undefined) out.offset = key.offset;
|
|
897
|
+
if (key.vertices !== undefined) out.vertices = key.vertices;
|
|
898
|
+
easing(key, out);
|
|
899
|
+
return out;
|
|
900
|
+
}),
|
|
901
|
+
};
|
|
902
|
+
// `skin` is absent for the default skin, which is the spec's own
|
|
903
|
+
// spelling (`MotionDeformTrack.skin`: absent = "default").
|
|
904
|
+
if (skinName !== 'default') entry.skin = skinName;
|
|
905
|
+
deform.push(entry);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// drawOrder — inverts `compileDrawOrder`. A key with no `offsets` restores the
|
|
912
|
+
// setup order; that is the parser's own encoding and the spec spells it the
|
|
913
|
+
// same way, so an absent array stays absent.
|
|
914
|
+
let drawOrder: JsonObject[] | undefined;
|
|
915
|
+
if (Array.isArray(anim.drawOrder)) {
|
|
916
|
+
drawOrder = anim.drawOrder.map((raw) => {
|
|
917
|
+
const key = obj(raw);
|
|
918
|
+
const out: JsonObject = { t: timeOf(key) };
|
|
919
|
+
if (Array.isArray(key.offsets)) {
|
|
920
|
+
out.offsets = key.offsets.map((o) => ({ slot: obj(o).slot, offset: obj(o).offset }));
|
|
921
|
+
}
|
|
922
|
+
return out;
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// events — inverts `compileEvents`. The payload fields are written only where
|
|
927
|
+
// the firing overrides the declared event's own, which is what the file holds.
|
|
928
|
+
let events: JsonObject[] | undefined;
|
|
929
|
+
if (Array.isArray(anim.events)) {
|
|
930
|
+
events = anim.events.map((raw) => {
|
|
931
|
+
const key = obj(raw);
|
|
932
|
+
const out: JsonObject = { t: timeOf(key), name: key.name };
|
|
933
|
+
for (const field of ['int', 'float', 'string', 'volume', 'balance']) {
|
|
934
|
+
if (key[field] !== undefined) out[field] = key[field];
|
|
935
|
+
}
|
|
936
|
+
return out;
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
for (const group of Object.keys(anim)) {
|
|
941
|
+
if (ANIMATION_GROUPS.includes(group)) continue;
|
|
942
|
+
note('blocker', 'ANIMATION_GROUP', `animation "${animName}"`, `group "${group}" is not one readAnimation reads, so it is dropped`);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// 🚨 There is no duration in skeleton JSON. The largest key time is the only
|
|
946
|
+
// derivable answer and it is what a runtime plays to; it is WRONG for an
|
|
947
|
+
// animation that holds its last pose past its last key, and nothing in the
|
|
948
|
+
// file distinguishes the two. Recorded per animation rather than hidden.
|
|
949
|
+
note(
|
|
950
|
+
'judgement',
|
|
951
|
+
'DURATION',
|
|
952
|
+
`animation "${animName}"`,
|
|
953
|
+
`skeleton JSON carries no duration; the largest key time (${maxT}) is used, which is what a runtime plays to. ` +
|
|
954
|
+
'An animation meant to hold past its last key needs the real number stated by hand',
|
|
955
|
+
);
|
|
956
|
+
|
|
957
|
+
const out: JsonObject = { duration: maxT, tracks };
|
|
958
|
+
if (ik.length) out.ik = ik;
|
|
959
|
+
if (transform.length) out.transform = transform;
|
|
960
|
+
if (deform.length) out.deform = deform;
|
|
961
|
+
if (drawOrder !== undefined) out.drawOrder = drawOrder;
|
|
962
|
+
if (events !== undefined) out.events = events;
|
|
963
|
+
return out;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* `ik` / `transform` — one unnamed timeline per constraint.
|
|
968
|
+
*
|
|
969
|
+
* Inverts `compileConstraintTrack`, and this is the one inversion that has to
|
|
970
|
+
* RESTATE rather than copy. Two reasons, and neither invents a value:
|
|
971
|
+
*
|
|
972
|
+
* 1. **The uniform field set.** Every field of these keys is optional with a
|
|
973
|
+
* per-key default, so `compileConstraintTrack` refuses a track whose keys do
|
|
974
|
+
* not all name the same fields — *"state it on every key or on none"*. An
|
|
975
|
+
* export does not obey that: it omits a field wherever it equals the default.
|
|
976
|
+
* So a field ANY key states is written on EVERY key, at the value the parser
|
|
977
|
+
* would have read there. Identical semantics, larger file.
|
|
978
|
+
* 2. 🚨 **`rigFlags`.** `compileConstraintTrack` stamps the rig constraint's
|
|
979
|
+
* non-default `bendPositive`/`compress`/`stretch` onto a key that omits one
|
|
980
|
+
* (issue #273). On rigc's own output that is self-consistent — rigc already
|
|
981
|
+
* wrote the flag on every key, so it is read back as stated. On a FOREIGN
|
|
982
|
+
* export it would change what plays: the export's omission means the per-key
|
|
983
|
+
* default, and the stamp would substitute the constraint's setup value. So a
|
|
984
|
+
* flag the constraint declares non-default is written on every key at the
|
|
985
|
+
* PARSER default, which is what the export actually plays.
|
|
986
|
+
*/
|
|
987
|
+
function constraintGroup(
|
|
988
|
+
group: 'ik' | 'transform',
|
|
989
|
+
animName: string,
|
|
990
|
+
anim: JsonObject,
|
|
991
|
+
root: JsonObject,
|
|
992
|
+
defaults: Record<string, number | boolean | string>,
|
|
993
|
+
seeT: (t: number) => void,
|
|
994
|
+
note: Note,
|
|
995
|
+
): JsonObject[] {
|
|
996
|
+
const fields = Object.keys(defaults);
|
|
997
|
+
const out: JsonObject[] = [];
|
|
998
|
+
for (const [name, keys] of arrEntries(anim[group])) {
|
|
999
|
+
const where = `animation "${animName}" ${group} "${name}"`;
|
|
1000
|
+
const stated = new Set<string>();
|
|
1001
|
+
for (const raw of keys) {
|
|
1002
|
+
const key = obj(raw);
|
|
1003
|
+
for (const field of fields) if (key[field] !== undefined) stated.add(field);
|
|
1004
|
+
}
|
|
1005
|
+
if (group === 'ik') {
|
|
1006
|
+
const constraint = arr(root.constraints)
|
|
1007
|
+
.map(obj)
|
|
1008
|
+
.find((c) => nameOf(c) === name && c.type === 'ik');
|
|
1009
|
+
for (const flag of IK_FLAGS) {
|
|
1010
|
+
if (constraint !== undefined && constraint[flag] !== undefined && constraint[flag] !== IK_KEY_DEFAULTS[flag]) {
|
|
1011
|
+
stated.add(flag);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
if (stated.size > 0 && stated.size < fields.length) {
|
|
1016
|
+
note(
|
|
1017
|
+
'lossy',
|
|
1018
|
+
'CONSTRAINT_KEY_RESTATED',
|
|
1019
|
+
where,
|
|
1020
|
+
`${fields.length - stated.size} field(s) the source omits are restated at the parser's default on every key, ` +
|
|
1021
|
+
'because the motion spec requires one field set per track — the same values the runtime reads, spelled out',
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
const ks = keys.map((raw) => {
|
|
1025
|
+
const key = obj(raw);
|
|
1026
|
+
const entry: JsonObject = { t: typeof key.time === 'number' ? key.time : 0 };
|
|
1027
|
+
seeT(entry.t as number);
|
|
1028
|
+
for (const field of fields) {
|
|
1029
|
+
if (!stated.has(field)) continue;
|
|
1030
|
+
if (key[field] !== undefined) entry[field] = key[field];
|
|
1031
|
+
else {
|
|
1032
|
+
const dflt = defaults[field];
|
|
1033
|
+
// `mixY`'s default is the same key's own `mixX`, spelled as that field
|
|
1034
|
+
// name in the table above.
|
|
1035
|
+
entry[field] = typeof dflt === 'string' ? (key[dflt] !== undefined ? key[dflt] : defaults[dflt]) : dflt;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
if (key.curve === 'stepped') entry.ease = 'stepped';
|
|
1039
|
+
else if (key.curve !== undefined) entry.curve = key.curve;
|
|
1040
|
+
for (const field of Object.keys(key)) {
|
|
1041
|
+
if (field === 'time' || field === 'curve' || fields.includes(field)) continue;
|
|
1042
|
+
note('blocker', `${group.toUpperCase()}_KEY_FIELD`, where, `key field "${field}" is not part of this timeline's shape`);
|
|
1043
|
+
}
|
|
1044
|
+
return entry;
|
|
1045
|
+
});
|
|
1046
|
+
out.push({ constraint: name, keys: ks });
|
|
1047
|
+
}
|
|
1048
|
+
return out;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* The provenance sentence both specs carry (INGEST §2.4's rule).
|
|
1053
|
+
*
|
|
1054
|
+
* ⚠️ A decompiled spec is indistinguishable from an authored one by inspection,
|
|
1055
|
+
* and every gate in this tree will call it green — because it IS green. No gate
|
|
1056
|
+
* catches a missing note, which is exactly why `ingest` writes one itself rather
|
|
1057
|
+
* than leaving it to the caller.
|
|
1058
|
+
*
|
|
1059
|
+
* 🔒 No timestamp, and that is a contract rather than a style: `A18` compares two
|
|
1060
|
+
* independent compiles byte for byte, and a dated note in a spec would break the
|
|
1061
|
+
* first rebuild from it.
|
|
1062
|
+
*/
|
|
1063
|
+
function provenanceNote(opts: IngestOptions, which: 'rig' | 'motion'): string {
|
|
1064
|
+
const head =
|
|
1065
|
+
`DECOMPILED from ${opts.source} by \`rigc ingest\` ${opts.version}. Every number here was read out of that ` +
|
|
1066
|
+
'skeleton; nothing was authored, so this file says what the object IS and nothing about why.';
|
|
1067
|
+
if (which === 'rig') {
|
|
1068
|
+
return (
|
|
1069
|
+
`${head} \`invariants\` is deliberately absent — a skeleton declares none, and an assertion with nothing to ` +
|
|
1070
|
+
'measure must SKIP rather than pass.'
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
return (
|
|
1074
|
+
`${head} Every curve is a raw \`curve\` array — the absolute (time, value) control points verbatim — because an ` +
|
|
1075
|
+
'export carries a different bezier per key per channel and no named easing can say that. Each `duration` is the ' +
|
|
1076
|
+
'largest key time in its animation, which is the only figure skeleton JSON supports.'
|
|
1077
|
+
);
|
|
1078
|
+
}
|