spine-rigc 0.30.0 → 0.32.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 +4 -4
- package/cli.ts +39 -1
- package/docs/AUTHORING.md +309 -48
- package/docs/INGEST.md +12 -10
- package/docs/SPEC_COVERAGE.md +6 -5
- package/package.json +1 -1
- package/src/atlas.ts +180 -0
- package/src/compile.ts +486 -21
- package/src/deformmeasure.ts +7 -2
- package/src/ingest.ts +254 -29
- package/src/motion.ts +120 -2
- package/src/rig.ts +179 -5
- package/src/timelines.ts +94 -6
- package/src/types.ts +106 -3
- package/src/validate.ts +618 -221
package/src/deformmeasure.ts
CHANGED
|
@@ -94,6 +94,7 @@ import {
|
|
|
94
94
|
type Attachment,
|
|
95
95
|
AtlasAttachmentLoader,
|
|
96
96
|
type Bone,
|
|
97
|
+
type CurveTimeline,
|
|
97
98
|
DeformTimeline,
|
|
98
99
|
FromProperty,
|
|
99
100
|
FromRotate,
|
|
@@ -1937,7 +1938,7 @@ function curveLegs(timeline: DeformTimeline, frame: number): { kind: DeformSpanC
|
|
|
1937
1938
|
}
|
|
1938
1939
|
|
|
1939
1940
|
/** Points `CurveTimeline.setBezier` stores per curve — `BEZIER_SIZE / 2`. */
|
|
1940
|
-
const BEZIER_POINTS = 9;
|
|
1941
|
+
export const BEZIER_POINTS = 9;
|
|
1941
1942
|
|
|
1942
1943
|
/**
|
|
1943
1944
|
* `CurveTimeline.curves`, which is `protected` and is read anyway.
|
|
@@ -1956,8 +1957,12 @@ const BEZIER_POINTS = 9;
|
|
|
1956
1957
|
*
|
|
1957
1958
|
* `A05` already gates the emitted curve arrays, and `DW18` is the control that
|
|
1958
1959
|
* the reading here matches what the runtime does with them.
|
|
1960
|
+
*
|
|
1961
|
+
* Exported for `validate.ts`'s `curveChannelValues` (issue #752), which asks the
|
|
1962
|
+
* same array what values a constraint's `mix` timeline poses between its keys —
|
|
1963
|
+
* so the reach into protected storage stays one reach, in this file.
|
|
1959
1964
|
*/
|
|
1960
|
-
function curveStorage(timeline:
|
|
1965
|
+
export function curveStorage(timeline: CurveTimeline): ArrayLike<number> {
|
|
1961
1966
|
return (timeline as unknown as { curves: ArrayLike<number> }).curves;
|
|
1962
1967
|
}
|
|
1963
1968
|
|
package/src/ingest.ts
CHANGED
|
@@ -44,7 +44,13 @@
|
|
|
44
44
|
* break `A18_DETERMINISTIC_EMIT` the first time anybody rebuilt from an ingested
|
|
45
45
|
* spec.
|
|
46
46
|
*/
|
|
47
|
-
import {
|
|
47
|
+
import {
|
|
48
|
+
composeSkinAttachmentName,
|
|
49
|
+
DEFAULT_SKIN,
|
|
50
|
+
PHYSICS_COMPONENTS,
|
|
51
|
+
SLOT_TRACKS as EMITTED_SLOT_TRACKS,
|
|
52
|
+
SPINE_VERSION,
|
|
53
|
+
} from './compile.ts';
|
|
48
54
|
import { CompileError } from './errors.ts';
|
|
49
55
|
import { CHANNELS_BY_KIND } from './timelines.ts';
|
|
50
56
|
import {
|
|
@@ -54,8 +60,8 @@ import {
|
|
|
54
60
|
spineGeneration,
|
|
55
61
|
TOPLEVEL_CONSTRAINT_ARRAYS,
|
|
56
62
|
} from './generation.ts';
|
|
57
|
-
import { MOTION_SPEC_VERSION, parseMotionSpec } from './motion.ts';
|
|
58
|
-
import { parseRigSpec, RIG_KEYS, RIG_SPEC_VERSION, type RigSpec } from './rig.ts';
|
|
63
|
+
import { EVERY_GLOBAL_PHYSICS, MOTION_SPEC_VERSION, parseMotionSpec } from './motion.ts';
|
|
64
|
+
import { parseRigSpec, resolveBoneInherit, RIG_KEYS, RIG_SPEC_VERSION, type RigSpec } from './rig.ts';
|
|
59
65
|
import type { MotionSpec } from './types.ts';
|
|
60
66
|
|
|
61
67
|
/**
|
|
@@ -374,6 +380,18 @@ const BONE_TRACKS: Record<string, TrackShape> = {
|
|
|
374
380
|
rotate: [['value', 0]],
|
|
375
381
|
};
|
|
376
382
|
|
|
383
|
+
/**
|
|
384
|
+
* The bone timeline whose key holds a NAME rather than numbers — `inherit`,
|
|
385
|
+
* `{ time, inherit }`, stepped by the format (its reader builds no curve). The
|
|
386
|
+
* field and the default `SkeletonJson`'s `inherit` branch reads where a key
|
|
387
|
+
* omits it: `getValue(aFrame, "inherit", "Normal")`, which the table spells
|
|
388
|
+
* `normal`. Inverts `compileValueTrack`'s named branch (issue #733).
|
|
389
|
+
*/
|
|
390
|
+
const INHERIT_TRACK = { property: 'inherit', field: 'inherit', dflt: 'normal' } as const;
|
|
391
|
+
|
|
392
|
+
/** Every bone timeline the motion spec has a track for, in the order the refusal prints them. */
|
|
393
|
+
const BONE_TRACK_NAMES = [...Object.keys(BONE_TRACKS), INHERIT_TRACK.property];
|
|
394
|
+
|
|
377
395
|
/**
|
|
378
396
|
* The eight physics timelines — `PHYSICS_TRACKS` in `compile.ts`, same order.
|
|
379
397
|
* `reset` carries no value at all — `compileValueTrack`'s zero-field branch.
|
|
@@ -516,7 +534,7 @@ export const UNSPELT_SLOT_TRACKS = Object.keys(CHANNELS_BY_KIND.slot).filter((na
|
|
|
516
534
|
export const INGEST_VOCABULARY = {
|
|
517
535
|
attachments: ATTACHMENT_TYPES,
|
|
518
536
|
constraints: Object.keys(CONSTRAINT_FIELDS),
|
|
519
|
-
boneTracks:
|
|
537
|
+
boneTracks: BONE_TRACK_NAMES,
|
|
520
538
|
slotTracks: SLOT_TRACKS,
|
|
521
539
|
path: Object.keys(PATH_TRACKS),
|
|
522
540
|
physics: Object.keys(PHYSICS_TRACKS),
|
|
@@ -694,6 +712,7 @@ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
|
|
|
694
712
|
perSlot[placeholder] = ingestAttachment(obj(att), placeholder, boneNames, opts, note, {
|
|
695
713
|
where: `skin "${nameOf(entry)}" slot "${slot}" attachment "${placeholder}"`,
|
|
696
714
|
contested: contested.get(slot)?.has(placeholder) === true,
|
|
715
|
+
skin: nameOf(entry),
|
|
697
716
|
});
|
|
698
717
|
}
|
|
699
718
|
table[slot] = perSlot;
|
|
@@ -1152,7 +1171,7 @@ function ingestAttachment(
|
|
|
1152
1171
|
boneNames: readonly string[],
|
|
1153
1172
|
opts: IngestOptions,
|
|
1154
1173
|
note: Note,
|
|
1155
|
-
at: { where: string; contested: boolean },
|
|
1174
|
+
at: { where: string; contested: boolean; skin: string },
|
|
1156
1175
|
): JsonObject {
|
|
1157
1176
|
// `readAttachment` reads no `type` as `region` (`SkeletonJson:539`), and so
|
|
1158
1177
|
// does `checkRigSpecKeys`. Both defaults are the parser's, not a guess.
|
|
@@ -1206,10 +1225,28 @@ function ingestAttachment(
|
|
|
1206
1225
|
? att.name
|
|
1207
1226
|
: undefined;
|
|
1208
1227
|
|
|
1228
|
+
/** What became of the atlas region once the name is gone — the same four shapes wherever the name is lost. */
|
|
1229
|
+
const regionFate =
|
|
1230
|
+
keptPath !== undefined
|
|
1231
|
+
? `That name was also the ATLAS REGION this attachment resolves, because \`path\` defaults to the name and ` +
|
|
1232
|
+
`not to the placeholder (\`SkeletonJson.js:526\`, \`:529\`, \`:560\`) — so the region key is KEPT as ` +
|
|
1233
|
+
`"path": ${JSON.stringify(keptPath)} and the name alone is lost`
|
|
1234
|
+
: att.path !== undefined
|
|
1235
|
+
? `The region key is the source's own \`path\` ${JSON.stringify(att.path)}, carried unchanged, so the ` +
|
|
1236
|
+
'name alone is lost'
|
|
1237
|
+
: resolvesRegion
|
|
1238
|
+
? at.contested
|
|
1239
|
+
? 'The region is the placeholder\'s either way — the compiler pins a composed name\'s `path` at the ' +
|
|
1240
|
+
'placeholder — so the name alone is lost'
|
|
1241
|
+
: 'The name is the placeholder itself, so the rebuild resolves the same atlas region and no `path` is ' +
|
|
1242
|
+
'written for it'
|
|
1243
|
+
: `An attachment of type ${JSON.stringify(type)} resolves no atlas region — the parser builds it from ` +
|
|
1244
|
+
'the name alone — so there is no region key to keep and the name is the whole loss';
|
|
1245
|
+
|
|
1209
1246
|
// `name` is DERIVED by rigc — `composeSkinAttachmentName` writes one exactly
|
|
1210
1247
|
// where a placeholder is contested — so the rig spec has no field for it and
|
|
1211
|
-
// it is dropped. Where
|
|
1212
|
-
//
|
|
1248
|
+
// it is dropped. Where one skin fills the placeholder every stated name is a
|
|
1249
|
+
// loss, because the rebuild writes none.
|
|
1213
1250
|
if (att.name !== undefined && !at.contested) {
|
|
1214
1251
|
note(
|
|
1215
1252
|
'lossy',
|
|
@@ -1217,18 +1254,43 @@ function ingestAttachment(
|
|
|
1217
1254
|
at.where,
|
|
1218
1255
|
`the attachment states name ${JSON.stringify(att.name)} and only ONE skin fills this placeholder, so rigc ` +
|
|
1219
1256
|
'writes no name on the rebuild — it composes "<skin>/<placeholder>" only for a contested placeholder (#541). ' +
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1257
|
+
regionFate,
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* 🚨 Where a placeholder IS contested the name is re-derived, and re-derived is
|
|
1263
|
+
* not the same as kept (issue #746). The rebuild's attachment answers to what
|
|
1264
|
+
* `composeSkinAttachmentName` returns; the source's answers to what it states,
|
|
1265
|
+
* or to its placeholder where it states nothing (`readAttachment`'s
|
|
1266
|
+
* `getValue(map, "name", placeholder)`, `SkeletonJson.js:526`). Only where
|
|
1267
|
+
* those two strings differ is anything renamed, so only there is a line.
|
|
1268
|
+
*
|
|
1269
|
+
* ⚠️ Comparing is the whole rule, and its cheap neighbour was measured and
|
|
1270
|
+
* rejected on #742: firing whenever a contested entry states a name puts a
|
|
1271
|
+
* `LOSS` on every contested round trip of rigc's own emits, which state
|
|
1272
|
+
* exactly the composed name and rebuild byte for byte.
|
|
1273
|
+
*
|
|
1274
|
+
* ⚠️ A contested placeholder the DEFAULT skin fills gets no line here: the
|
|
1275
|
+
* compiler composes nothing for it — `refuseDefaultSkinContest` refuses the
|
|
1276
|
+
* rebuild by name — so a line claiming a composed name would state one no
|
|
1277
|
+
* build will ever emit.
|
|
1278
|
+
*/
|
|
1279
|
+
const composed = composeSkinAttachmentName(at.skin, placeholder, at.contested);
|
|
1280
|
+
const sourceName = att.name === undefined ? placeholder : att.name;
|
|
1281
|
+
if (composed !== null && at.skin !== DEFAULT_SKIN && sourceName !== composed) {
|
|
1282
|
+
note(
|
|
1283
|
+
'lossy',
|
|
1284
|
+
'ATTACHMENT_NAME',
|
|
1285
|
+
at.where,
|
|
1286
|
+
(att.name === undefined
|
|
1287
|
+
? `the attachment states no name, so the source names it by its placeholder ${JSON.stringify(placeholder)} ` +
|
|
1288
|
+
'(`SkeletonJson.js:526`)'
|
|
1289
|
+
: `the attachment states name ${JSON.stringify(att.name)}`) +
|
|
1290
|
+
`, and more than one skin fills this placeholder, so rigc composes the name ${JSON.stringify(composed)} ` +
|
|
1291
|
+
`on the rebuild (#541) — the rig spec has no field for a name, so the rebuilt attachment answers to ` +
|
|
1292
|
+
`${JSON.stringify(composed)} where the source's answered to ${JSON.stringify(sourceName)}. ` +
|
|
1293
|
+
regionFate,
|
|
1232
1294
|
);
|
|
1233
1295
|
}
|
|
1234
1296
|
|
|
@@ -1340,18 +1402,67 @@ function ingestAttachment(
|
|
|
1340
1402
|
return out;
|
|
1341
1403
|
}
|
|
1342
1404
|
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1405
|
+
// A numbered image series (issue #729). Carried as the file states it —
|
|
1406
|
+
// the four fields `readSequence` reads — wherever the rig spec can say it;
|
|
1407
|
+
// what is left for the blocker is a block the parser reads into a series
|
|
1408
|
+
// that is not the one written, or one on a kind the parser never reads it on.
|
|
1409
|
+
// `null` is the parser's own absent (`getValue(map, "sequence", null)`), so it
|
|
1410
|
+
// is read as no series rather than as a malformed one.
|
|
1411
|
+
if (att.sequence !== undefined && att.sequence !== null) {
|
|
1412
|
+
const refusal = resolvesRegion
|
|
1413
|
+
? sequenceBlockRefusal(att.sequence)
|
|
1414
|
+
: `the attachment is a ${type}, and \`readAttachment\` reads a \`sequence\` only on a region or a mesh ` +
|
|
1415
|
+
'(`SkeletonJson.js:530`, `:561`) — the parser never read this one, and the rig spec refuses it there by name';
|
|
1416
|
+
if (refusal === null) {
|
|
1417
|
+
const seq = att.sequence as JsonObject;
|
|
1418
|
+
const carried: JsonObject = {};
|
|
1419
|
+
for (const field of ['count', 'start', 'digits', 'setup']) if (seq[field] !== undefined) carried[field] = seq[field];
|
|
1420
|
+
out.sequence = carried;
|
|
1421
|
+
// The frames ARE the art: `<path><number>` regions, which on the loose
|
|
1422
|
+
// route are PNGs of those names. A single `image` would name one region
|
|
1423
|
+
// the series does not have, and the rig spec refuses the pair.
|
|
1424
|
+
delete out.image;
|
|
1425
|
+
} else {
|
|
1426
|
+
note(
|
|
1427
|
+
'blocker',
|
|
1428
|
+
'ATTACHMENT_SEQUENCE',
|
|
1429
|
+
at.where,
|
|
1430
|
+
`the attachment's \`sequence\` block is ${JSON.stringify(att.sequence)}: ${refusal}. The rebuild draws the ` +
|
|
1431
|
+
'single region this attachment names instead of a series',
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1351
1434
|
}
|
|
1352
1435
|
return out;
|
|
1353
1436
|
}
|
|
1354
1437
|
|
|
1438
|
+
/**
|
|
1439
|
+
* Why a `sequence` block cannot be carried as written, or `null` when it can —
|
|
1440
|
+
* the rig spec's own refusals (`checkRigSequence`), stated about a file.
|
|
1441
|
+
*
|
|
1442
|
+
* Every one of them is a series the parser loads into something other than
|
|
1443
|
+
* what the file says (issue #729): no `count` is 0 regions, a `setup` past the
|
|
1444
|
+
* end is clamped, a fraction names a region like `stem1.5`.
|
|
1445
|
+
*/
|
|
1446
|
+
function sequenceBlockRefusal(seq: unknown): string | null {
|
|
1447
|
+
if (typeof seq !== 'object' || seq === null || Array.isArray(seq)) {
|
|
1448
|
+
return 'a sequence is an object of `count`, `start`, `digits` and `setup`';
|
|
1449
|
+
}
|
|
1450
|
+
const block = seq as JsonObject;
|
|
1451
|
+
if (block.count === undefined) {
|
|
1452
|
+
return 'it states no `count`, and `readSequence` reads 0 — the attachment loads holding no region and draws nothing';
|
|
1453
|
+
}
|
|
1454
|
+
for (const [field, min] of [['count', 1], ['start', 0], ['digits', 0], ['setup', 0]] as const) {
|
|
1455
|
+
const value = block[field];
|
|
1456
|
+
if (value !== undefined && (typeof value !== 'number' || !Number.isInteger(value) || value < min)) {
|
|
1457
|
+
return `\`${field}\` is ${JSON.stringify(value)}, and the rig spec takes a whole number of at least ${min} there`;
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
if (typeof block.setup === 'number' && block.setup >= (block.count as number)) {
|
|
1461
|
+
return `\`setup\` ${block.setup} is past the end of a ${String(block.count)}-frame series, and \`Sequence.resolveIndex\` clamps it to the last frame`;
|
|
1462
|
+
}
|
|
1463
|
+
return null;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1355
1466
|
/** One animation. Inverts step 5 of `compile()` — the whole timeline half. */
|
|
1356
1467
|
function ingestAnimation(
|
|
1357
1468
|
animName: string,
|
|
@@ -1448,6 +1559,16 @@ function ingestAnimation(
|
|
|
1448
1559
|
* `lossy`, on the argument that the rebuilt skeleton plays identically — is
|
|
1449
1560
|
* a decision about all three families and is not made here.
|
|
1450
1561
|
*/
|
|
1562
|
+
/**
|
|
1563
|
+
* The physics constraints the rebuild carries — every one the file declares
|
|
1564
|
+
* but those `ingest` omits as inert — which is what an unnamed physics
|
|
1565
|
+
* timeline can reach in it.
|
|
1566
|
+
*/
|
|
1567
|
+
const carriedPhysics = arr(root.constraints)
|
|
1568
|
+
.map(obj)
|
|
1569
|
+
.filter((one) => one.type === 'physics' && !(typeof one.name === 'string' && inert.has(one.name)));
|
|
1570
|
+
/** Unnamed physics timelines that reach nothing in the rebuild, said once the duration is known. */
|
|
1571
|
+
const unreached: Array<{ property: string; lastKey: number }> = [];
|
|
1451
1572
|
const family = (group: 'path' | 'physics' | 'slider', shapes: Record<string, TrackShape>): void => {
|
|
1452
1573
|
for (const [name, timelines] of objEntries(anim[group])) {
|
|
1453
1574
|
for (const [property, keys] of arrEntries(timelines)) {
|
|
@@ -1471,6 +1592,26 @@ function ingestAnimation(
|
|
|
1471
1592
|
note('blocker', `${group.toUpperCase()}_TIMELINE`, where, `timeline "${property}" is not in the motion spec`);
|
|
1472
1593
|
continue;
|
|
1473
1594
|
}
|
|
1595
|
+
// The empty name is the physics group's timeline that names no
|
|
1596
|
+
// constraint (issue #726), which the motion spec spells `"*"`. It
|
|
1597
|
+
// writes every carried constraint declaring the property global —
|
|
1598
|
+
// `reset` every one — and one that reaches none is a no-op the rebuild
|
|
1599
|
+
// would be refused over by name, so it goes, and is said, instead.
|
|
1600
|
+
if (group === 'physics' && name === '') {
|
|
1601
|
+
const reaches =
|
|
1602
|
+
property === 'reset' ? carriedPhysics.length > 0 : carriedPhysics.some((one) => Boolean(one[`${property}Global`]));
|
|
1603
|
+
if (!reaches) {
|
|
1604
|
+
let lastKey = 0;
|
|
1605
|
+
for (const raw of keys) {
|
|
1606
|
+
const t = obj(raw).time;
|
|
1607
|
+
if (typeof t === 'number' && t > lastKey) lastKey = t;
|
|
1608
|
+
}
|
|
1609
|
+
unreached.push({ property, lastKey });
|
|
1610
|
+
continue;
|
|
1611
|
+
}
|
|
1612
|
+
valueTrack({ physics: EVERY_GLOBAL_PHYSICS }, property, keys, shape, where);
|
|
1613
|
+
continue;
|
|
1614
|
+
}
|
|
1474
1615
|
valueTrack({ [group]: name }, property, keys, shape, where);
|
|
1475
1616
|
}
|
|
1476
1617
|
}
|
|
@@ -1480,12 +1621,46 @@ function ingestAnimation(
|
|
|
1480
1621
|
for (const [property, keys] of arrEntries(timelines)) {
|
|
1481
1622
|
const shape = BONE_TRACKS[property];
|
|
1482
1623
|
const where = `animation "${animName}" bone "${bone}" ${property}`;
|
|
1624
|
+
if (property === INHERIT_TRACK.property) {
|
|
1625
|
+
// `{ time, inherit }` -> `{ t, v: mode }`. The mode is carried in the
|
|
1626
|
+
// table's spelling, which is what `build` writes back; a key that omits
|
|
1627
|
+
// it, or spells it with a capital the runtime also folds, is a key the
|
|
1628
|
+
// rebuild states differently and is counted as such. A spelling the
|
|
1629
|
+
// runtime cannot resolve at all is carried as written — the file plays
|
|
1630
|
+
// no mode there, and `build` refuses it by name rather than guessing one.
|
|
1631
|
+
let restated = 0;
|
|
1632
|
+
const out = keys.map((raw) => {
|
|
1633
|
+
const key = obj(raw);
|
|
1634
|
+
const entry: JsonObject = { t: timeOf(key) };
|
|
1635
|
+
const written = key[INHERIT_TRACK.field];
|
|
1636
|
+
const mode = written === undefined ? INHERIT_TRACK.dflt : resolveBoneInherit(written);
|
|
1637
|
+
if (mode !== written && mode !== undefined) restated++;
|
|
1638
|
+
entry.v = mode ?? (written as JsonObject[string]);
|
|
1639
|
+
for (const field of Object.keys(key)) {
|
|
1640
|
+
if (field === 'time' || field === INHERIT_TRACK.field) continue;
|
|
1641
|
+
note('blocker', 'TIMELINE_FIELD', where, `key field "${field}" is not part of this timeline's shape`);
|
|
1642
|
+
}
|
|
1643
|
+
return entry;
|
|
1644
|
+
});
|
|
1645
|
+
if (restated > 0) {
|
|
1646
|
+
note(
|
|
1647
|
+
'lossy',
|
|
1648
|
+
'TIMELINE_KEY_RESTATED',
|
|
1649
|
+
where,
|
|
1650
|
+
`${restated} key(s) omit the mode or spell it with a capital first letter, and are written out as the ` +
|
|
1651
|
+
`mode the runtime reads there (an omitted one is ${INHERIT_TRACK.dflt}) — the same mode, in the ` +
|
|
1652
|
+
'spelling the editor writes',
|
|
1653
|
+
);
|
|
1654
|
+
}
|
|
1655
|
+
tracks.push({ bone, property, keys: out });
|
|
1656
|
+
continue;
|
|
1657
|
+
}
|
|
1483
1658
|
if (shape === undefined) {
|
|
1484
1659
|
note(
|
|
1485
1660
|
'blocker',
|
|
1486
1661
|
'BONE_TIMELINE',
|
|
1487
1662
|
where,
|
|
1488
|
-
`timeline "${property}" has no track in the motion spec — a bone track is ${
|
|
1663
|
+
`timeline "${property}" has no track in the motion spec — a bone track is ${BONE_TRACK_NAMES.join(', ')} ` +
|
|
1489
1664
|
'and nothing else, so the rebuild plays nothing here',
|
|
1490
1665
|
);
|
|
1491
1666
|
continue;
|
|
@@ -1608,13 +1783,42 @@ function ingestAnimation(
|
|
|
1608
1783
|
// Inverts `compileDeformTrack`, whose emitted key is `{time, offset?, vertices?}`
|
|
1609
1784
|
// and whose `offset` is omitted at 0 (the parser's default).
|
|
1610
1785
|
const deform: JsonObject[] = [];
|
|
1786
|
+
// sequence — the other attachment timeline, inverting `compileSequenceTrack`:
|
|
1787
|
+
// `{time, mode?, index?, delay?}` with each field written only where the file
|
|
1788
|
+
// wrote it, because each has a parser default (`"hold"`, 0, the previous
|
|
1789
|
+
// key's delay) and restating one would be a rebuild saying more than the file.
|
|
1790
|
+
const sequence: JsonObject[] = [];
|
|
1611
1791
|
for (const [skinName, perSkin] of objEntries(anim.attachments)) {
|
|
1612
1792
|
for (const [slot, perSlot] of objEntries(perSkin)) {
|
|
1613
1793
|
for (const [attachment, timelines] of objEntries(perSlot)) {
|
|
1614
1794
|
for (const [property, keys] of arrEntries(timelines)) {
|
|
1615
1795
|
const where = `animation "${animName}" ${skinName}/${slot}/${attachment}`;
|
|
1796
|
+
if (property === 'sequence') {
|
|
1797
|
+
const entry: JsonObject = {
|
|
1798
|
+
slot,
|
|
1799
|
+
attachment,
|
|
1800
|
+
keys: keys.map((raw) => {
|
|
1801
|
+
const key = obj(raw);
|
|
1802
|
+
const out: JsonObject = { t: timeOf(key) };
|
|
1803
|
+
for (const field of ['mode', 'index', 'delay']) if (key[field] !== undefined) out[field] = key[field];
|
|
1804
|
+
return out;
|
|
1805
|
+
}),
|
|
1806
|
+
};
|
|
1807
|
+
if (skinName !== 'default') entry.skin = skinName;
|
|
1808
|
+
sequence.push(entry);
|
|
1809
|
+
continue;
|
|
1810
|
+
}
|
|
1616
1811
|
if (property !== 'deform') {
|
|
1617
|
-
|
|
1812
|
+
// Reached only by a name OUTSIDE the format: `readAnimation` tests
|
|
1813
|
+
// an attachment timeline for "deform" and "sequence" and reads
|
|
1814
|
+
// nothing else (`SkeletonJson.js:1147-1201`), so no player plays it.
|
|
1815
|
+
note(
|
|
1816
|
+
'blocker',
|
|
1817
|
+
'ATTACHMENT_TIMELINE',
|
|
1818
|
+
where,
|
|
1819
|
+
`timeline "${property}" is not an attachment timeline the format has — the runtime's reader tests for ` +
|
|
1820
|
+
'"deform" and "sequence" and ignores anything else, and those two are what the motion spec carries',
|
|
1821
|
+
);
|
|
1618
1822
|
continue;
|
|
1619
1823
|
}
|
|
1620
1824
|
const entry: JsonObject = {
|
|
@@ -1667,6 +1871,26 @@ function ingestAnimation(
|
|
|
1667
1871
|
});
|
|
1668
1872
|
}
|
|
1669
1873
|
|
|
1874
|
+
for (const { property, lastKey } of unreached) {
|
|
1875
|
+
const flag = `${property}Global`;
|
|
1876
|
+
note(
|
|
1877
|
+
'lossy',
|
|
1878
|
+
'PHYSICS_GLOBAL_REACHES_NOTHING',
|
|
1879
|
+
`animation "${animName}" physics "" ${property}`,
|
|
1880
|
+
'names no constraint, so the runtime writes it into every physics constraint ' +
|
|
1881
|
+
(property === 'reset' ? 'the skeleton has' : `declaring "${flag}"`) +
|
|
1882
|
+
', and the rebuild carries ' +
|
|
1883
|
+
(carriedPhysics.length === 0
|
|
1884
|
+
? 'no physics constraint'
|
|
1885
|
+
: `none that does (${carriedPhysics.map((one) => `"${String(one.name)}"`).join(', ')})`) +
|
|
1886
|
+
` — it moves nothing, and \`build\` would refuse its \`"physics": "${EVERY_GLOBAL_PHYSICS}"\` track by name. ` +
|
|
1887
|
+
'The motion spec omits it' +
|
|
1888
|
+
(lastKey > maxT
|
|
1889
|
+
? `, and an animation's duration is the last key it has left, so the rebuilt animation ends at ${maxT}s rather than ${lastKey}s`
|
|
1890
|
+
: '; the rebuild differs from the source by exactly this no-op'),
|
|
1891
|
+
);
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1670
1894
|
for (const group of Object.keys(anim)) {
|
|
1671
1895
|
if (ANIMATION_GROUPS.includes(group)) continue;
|
|
1672
1896
|
note(
|
|
@@ -1694,6 +1918,7 @@ function ingestAnimation(
|
|
|
1694
1918
|
if (ik.length) out.ik = ik;
|
|
1695
1919
|
if (transform.length) out.transform = transform;
|
|
1696
1920
|
if (deform.length) out.deform = deform;
|
|
1921
|
+
if (sequence.length) out.sequence = sequence;
|
|
1697
1922
|
if (drawOrder !== undefined) out.drawOrder = drawOrder;
|
|
1698
1923
|
if (events !== undefined) out.events = events;
|
|
1699
1924
|
return out;
|
package/src/motion.ts
CHANGED
|
@@ -55,10 +55,29 @@
|
|
|
55
55
|
*/
|
|
56
56
|
import { CompileError } from './errors.ts';
|
|
57
57
|
import { refuseUnknownKeys } from './keys.ts';
|
|
58
|
+
import { SEQUENCE_MODES } from './timelines.ts';
|
|
58
59
|
import type { MotionSpec } from './types.ts';
|
|
59
60
|
|
|
60
61
|
export const MOTION_SPEC_VERSION = 'rigc-motion/1';
|
|
61
62
|
|
|
63
|
+
/**
|
|
64
|
+
* A track's `physics` target for the timeline that names NO constraint (issue
|
|
65
|
+
* #726) — the one the runtime applies to every physics constraint whose own data
|
|
66
|
+
* declares the keyed property global (`"strengthGlobal": true` for `strength`,
|
|
67
|
+
* and so on; `reset` resets every physics constraint and asks no flag).
|
|
68
|
+
* `compile` emits it under the empty name, which is the skeleton file's own
|
|
69
|
+
* spelling of it (`SkeletonJson.js:1048-1054`).
|
|
70
|
+
*
|
|
71
|
+
* 🔑 Not the empty string itself, and that is the choice rather than a detail:
|
|
72
|
+
* `""` is the likeliest shape of a value somebody forgot to fill in, and a
|
|
73
|
+
* forgotten target that silently became "every global constraint" is the exact
|
|
74
|
+
* silence this format exists to name. So `"physics": ""` is refused by name and
|
|
75
|
+
* points here, and `"*"` is reserved the other way round: `compile` refuses a
|
|
76
|
+
* physics constraint that is CALLED `"*"`, because a track naming it could not
|
|
77
|
+
* say which of the two it meant.
|
|
78
|
+
*/
|
|
79
|
+
export const EVERY_GLOBAL_PHYSICS = '*';
|
|
80
|
+
|
|
62
81
|
/**
|
|
63
82
|
* The six fields that pick a track's target family. Listed here as well as in
|
|
64
83
|
* `compile`'s `resolveTargets` because the two ask different questions of it:
|
|
@@ -112,7 +131,7 @@ export const MOTION_KEYS = {
|
|
|
112
131
|
'bone', 'x', 'y', 'rotate', 'scaleX', 'shearX', 'inertia', 'strength', 'damping', 'mass', 'wind', 'gravity',
|
|
113
132
|
'mix', 'fps', 'limit', 'note',
|
|
114
133
|
],
|
|
115
|
-
MotionAnimation: ['duration', 'loop', 'note', 'tracks', 'ik', 'transform', 'deform', 'drawOrder', 'events'],
|
|
134
|
+
MotionAnimation: ['duration', 'loop', 'note', 'tracks', 'ik', 'transform', 'deform', 'sequence', 'drawOrder', 'events'],
|
|
116
135
|
MotionTrack: ['slot', 'group', 'bone', 'physics', 'path', 'slider', 'property', 'lag', 'stagger', 'keys'],
|
|
117
136
|
MotionKey: ['t', 'v', 'derive', 'ease', 'curve'],
|
|
118
137
|
MotionIkTrack: ['constraint', 'keys'],
|
|
@@ -121,6 +140,8 @@ export const MOTION_KEYS = {
|
|
|
121
140
|
MotionTransformKey: ['t', 'mixRotate', 'mixX', 'mixY', 'mixScaleX', 'mixScaleY', 'mixShearY', 'ease', 'curve'],
|
|
122
141
|
MotionDeformTrack: ['skin', 'slot', 'attachment', 'keys'],
|
|
123
142
|
MotionDeformKey: ['t', 'offset', 'fromVertex', 'vertices', 'transform', 'ease', 'curve'],
|
|
143
|
+
MotionSequenceTrack: ['skin', 'slot', 'attachment', 'keys'],
|
|
144
|
+
MotionSequenceKey: ['t', 'mode', 'index', 'delay'],
|
|
124
145
|
MotionDrawOrderKey: ['t', 'offsets'],
|
|
125
146
|
MotionDrawOrderOffset: ['slot', 'offset'],
|
|
126
147
|
MotionEventKey: ['t', 'name', 'int', 'float', 'string', 'volume', 'balance'],
|
|
@@ -292,7 +313,15 @@ function parsePhysics(raw: unknown, where: string): void {
|
|
|
292
313
|
const table = needObj(raw, where, 'physics', 'it is a table keyed by constraint name, `{ "<name>": { "bone": … } }`');
|
|
293
314
|
for (const [name, entry] of Object.entries(table)) {
|
|
294
315
|
const key = `physics."${name}"`;
|
|
295
|
-
|
|
316
|
+
if (name === EVERY_GLOBAL_PHYSICS) {
|
|
317
|
+
throw new CompileError(
|
|
318
|
+
`${where}: \`${key}\` names a physics constraint "${EVERY_GLOBAL_PHYSICS}", and that name is reserved: a ` +
|
|
319
|
+
`track's \`"physics": "${EVERY_GLOBAL_PHYSICS}"\` is the timeline that names no constraint and drives ` +
|
|
320
|
+
'every one declaring the keyed property global, so a constraint called ' +
|
|
321
|
+
`"${EVERY_GLOBAL_PHYSICS}" could not be keyed by name. Give it another name`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
const spec = needObj(entry, where, key,'a physics constraint is an object naming the bone it drives and the components it drives it in');
|
|
296
325
|
known(spec, 'MotionPhysics', where, key);
|
|
297
326
|
needString(spec.bone, where, `${key}.bone`, 'a physics constraint drives one bone, named here');
|
|
298
327
|
for (const field of PHYSICS_NUMBERS) {
|
|
@@ -378,6 +407,17 @@ function parseTracks(raw: unknown, where: string, at: string): void {
|
|
|
378
407
|
for (const field of TARGET_FIELDS) {
|
|
379
408
|
optString(track[field], where, `${key}.${field}`, `a track's "${field}" is the name of the ${field === 'slot' || field === 'bone' ? field : `${field} it targets`}`);
|
|
380
409
|
}
|
|
410
|
+
if (track.physics === '') {
|
|
411
|
+
refuse(
|
|
412
|
+
where,
|
|
413
|
+
`${key}.physics`,
|
|
414
|
+
track.physics,
|
|
415
|
+
'the empty name is how a skeleton file spells a physics timeline that names no constraint, and a motion ' +
|
|
416
|
+
`spec spells that "${EVERY_GLOBAL_PHYSICS}" — it drives every physics constraint that declares the keyed ` +
|
|
417
|
+
'property global (`"strengthGlobal": true` for `strength`). Name one constraint, or write ' +
|
|
418
|
+
`"${EVERY_GLOBAL_PHYSICS}"`,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
381
421
|
optFinite(track.lag, where, `${key}.lag`, '"lag" is seconds added to every key time of this track, so a finite number — a string is CONCATENATED onto each time and a boolean adds 1');
|
|
382
422
|
optFinite(track.stagger, where, `${key}.stagger`, '"stagger" is the extra per-member delay inside a group, in seconds, so a finite number');
|
|
383
423
|
const keys = needArray(track.keys, where, `${key}.keys`, 'it is an array of `{ t, v }` keys');
|
|
@@ -442,6 +482,83 @@ function parseDeform(raw: unknown, where: string, at: string): void {
|
|
|
442
482
|
}
|
|
443
483
|
}
|
|
444
484
|
|
|
485
|
+
/**
|
|
486
|
+
* `sequence` — which frame of an attachment's numbered series shows.
|
|
487
|
+
*
|
|
488
|
+
* Everything decidable from the key alone is decided here; what needs the rig
|
|
489
|
+
* (does the attachment carry a `sequence` block, is `index` inside its `count`)
|
|
490
|
+
* is `compile`'s. Each refusal is a key the parser loads without a word and
|
|
491
|
+
* plays as something else — measured on spine-core 4.3.13 (issue #729):
|
|
492
|
+
*
|
|
493
|
+
* - a `mode` outside the seven — `SequenceMode[name]` is `undefined`, the mode
|
|
494
|
+
* bits store 0, and the key plays as `hold`;
|
|
495
|
+
* - an `index` that is not a whole number of at least 0 — it is stored as
|
|
496
|
+
* `index << 4`, so `1.5` shows frame 1 and a negative one indexes before the
|
|
497
|
+
* series;
|
|
498
|
+
* - a `delay` that is not a number of at least 0 — and, under a mode that
|
|
499
|
+
* advances, an EFFECTIVE delay of 0: the parser carries a key's delay from
|
|
500
|
+
* the key before (`lastDelay`, 0 on the first), `(time - keyTime) / 0` is
|
|
501
|
+
* `Infinity` and `Infinity | 0` is 0, so a `loop` at delay 0 shows its
|
|
502
|
+
* first frame for the whole key.
|
|
503
|
+
*/
|
|
504
|
+
function parseSequence(raw: unknown, where: string, at: string): void {
|
|
505
|
+
if (raw === undefined) return;
|
|
506
|
+
const entries = needArray(raw, where, `${at}.sequence`, 'it is an array of `{ slot, attachment, keys }` entries — one per skin/slot/attachment triple');
|
|
507
|
+
for (const [i, entry] of entries.entries()) {
|
|
508
|
+
const key = `${at}.sequence[${i}]`;
|
|
509
|
+
const track = needObj(entry, where, key, 'a sequence timeline is an object of `{ skin?, slot, attachment, keys }`');
|
|
510
|
+
known(track, 'MotionSequenceTrack', where, key);
|
|
511
|
+
optString(track.skin, where, `${key}.skin`, 'it names the skin the attachment lives in; absent means "default"');
|
|
512
|
+
needString(track.slot, where, `${key}.slot`, 'a sequence timeline steps one attachment of one slot, named here');
|
|
513
|
+
needString(track.attachment, where, `${key}.attachment`, "it is the attachment's placeholder name inside that skin and slot");
|
|
514
|
+
const keys = needArray(track.keys, where, `${key}.keys`, 'it is an array of `{ t, mode?, index?, delay? }` keys');
|
|
515
|
+
// The delay the PARSER will read at each key: the stated one, else the
|
|
516
|
+
// previous key's, else 0 (`lastDelay` in `readAnimation`).
|
|
517
|
+
let carried = 0;
|
|
518
|
+
for (const [j, k] of keys.entries()) {
|
|
519
|
+
const kat = `${key}.keys[${j}]`;
|
|
520
|
+
const parsed = parseKey(k, where, kat, 'a sequence key is an object of `{ t, mode?, index?, delay? }`', 'MotionSequenceKey');
|
|
521
|
+
if (parsed.mode !== undefined && !(SEQUENCE_MODES as readonly unknown[]).includes(parsed.mode)) {
|
|
522
|
+
refuse(
|
|
523
|
+
where,
|
|
524
|
+
`${kat}.mode`,
|
|
525
|
+
parsed.mode,
|
|
526
|
+
`a sequence mode is one of the ${SEQUENCE_MODES.length} the format has — ${SEQUENCE_MODES.join(', ')} ` +
|
|
527
|
+
'(absent means "hold"). The parser reads `SequenceMode[mode]`, which is undefined for anything else, and ' +
|
|
528
|
+
'stores mode bits 0: the key would load without a word and play as "hold"',
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
const index = parsed.index;
|
|
532
|
+
if (index !== undefined && (typeof index !== 'number' || !Number.isInteger(index) || index < 0)) {
|
|
533
|
+
refuse(
|
|
534
|
+
where,
|
|
535
|
+
`${kat}.index`,
|
|
536
|
+
index,
|
|
537
|
+
'it is the 0-based frame this key starts on, so a whole number of at least 0 — the runtime stores it as ' +
|
|
538
|
+
'`index << 4`, which truncates a fraction (1.5 showed frame 1) and puts a negative one before the series',
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
const delay = parsed.delay;
|
|
542
|
+
if (delay !== undefined && (typeof delay !== 'number' || !Number.isFinite(delay) || delay < 0)) {
|
|
543
|
+
refuse(where, `${kat}.delay`, delay, 'it is the seconds each frame shows for, so a finite number of at least 0');
|
|
544
|
+
}
|
|
545
|
+
if (typeof delay === 'number') carried = delay;
|
|
546
|
+
const mode = typeof parsed.mode === 'string' ? parsed.mode : 'hold';
|
|
547
|
+
if (mode !== 'hold' && carried === 0) {
|
|
548
|
+
throw new CompileError(
|
|
549
|
+
`${where}: \`${kat}\` plays "${mode}" at a delay of 0` +
|
|
550
|
+
(delay === undefined
|
|
551
|
+
? ` — it states none, and the parser carries the previous key's (${j === 0 ? 'there is none, so 0' : '0'})`
|
|
552
|
+
: '') +
|
|
553
|
+
'. The runtime advances the frame by `(time - keyTime) / delay`, which is Infinity at 0, and ' +
|
|
554
|
+
'`Infinity | 0` is 0 — so the key shows its first frame for as long as it lasts, which is "hold" spelt ' +
|
|
555
|
+
`as "${mode}". State the seconds per frame, or write "hold"`,
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
445
562
|
function parseEvents(raw: unknown, where: string, at: string): void {
|
|
446
563
|
if (raw === undefined) return;
|
|
447
564
|
const keys = needArray(raw, where, `${at}.events`, 'it is an array of `{ t, name }` firings — one timeline per animation, naming no target');
|
|
@@ -503,6 +620,7 @@ function parseAnimation(raw: unknown, where: string, name: string): void {
|
|
|
503
620
|
parseTracks(anim.tracks, where, at);
|
|
504
621
|
for (const group of CONSTRAINT_GROUPS) parseConstraintTracks(anim[group], where, at, group);
|
|
505
622
|
parseDeform(anim.deform, where, at);
|
|
623
|
+
parseSequence(anim.sequence, where, at);
|
|
506
624
|
parseDrawOrder(anim.drawOrder, where, at);
|
|
507
625
|
parseEvents(anim.events, where, at);
|
|
508
626
|
}
|