spine-rigc 0.28.0 → 0.30.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 +10 -0
- package/docs/AUTHORING.md +290 -105
- package/docs/INGEST.md +15 -2
- package/docs/SPEC_COVERAGE.md +5 -4
- package/package.json +1 -1
- package/src/compile.ts +480 -308
- package/src/ingest.ts +248 -22
- package/src/png.ts +162 -7
- package/src/rig.ts +32 -21
- package/src/timelines.ts +101 -13
- package/src/types.ts +13 -7
- package/src/validate.ts +541 -28
- package/tools/editor_roundtrip.ts +1 -1
- package/tools/plate.ts +6 -0
package/src/ingest.ts
CHANGED
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
* break `A18_DETERMINISTIC_EMIT` the first time anybody rebuilt from an ingested
|
|
45
45
|
* spec.
|
|
46
46
|
*/
|
|
47
|
-
import { SLOT_TRACKS as EMITTED_SLOT_TRACKS, SPINE_VERSION } from './compile.ts';
|
|
47
|
+
import { PHYSICS_COMPONENTS, SLOT_TRACKS as EMITTED_SLOT_TRACKS, SPINE_VERSION } from './compile.ts';
|
|
48
48
|
import { CompileError } from './errors.ts';
|
|
49
49
|
import { CHANNELS_BY_KIND } from './timelines.ts';
|
|
50
50
|
import {
|
|
@@ -292,6 +292,47 @@ const CONSTRAINT_FIELDS: Record<string, string[]> = {
|
|
|
292
292
|
slider: carried('RigSliderConstraint', 'name', 'type'),
|
|
293
293
|
};
|
|
294
294
|
|
|
295
|
+
/**
|
|
296
|
+
* The physics constraints that drive nothing, by name, each with the component
|
|
297
|
+
* fields it DOES state — every one of them at most 0 (issue #731).
|
|
298
|
+
*
|
|
299
|
+
* 🔑 The runtime's own predicate, not a reading of it: `PhysicsConstraint.update`
|
|
300
|
+
* decides what it applies with `this.data.x > 0` and its four siblings
|
|
301
|
+
* (`PhysicsConstraint.js:112`) and nothing else, so a constraint with none of
|
|
302
|
+
* `PHYSICS_COMPONENTS` above 0 moves no bone. `build` refuses exactly that shape
|
|
303
|
+
* by name — `A23_PHYSICS_CONSTRAINT_EFFECTIVE` at the gate — so carrying one
|
|
304
|
+
* through made the decompiled spec of a file an editor exports unbuildable as a
|
|
305
|
+
* whole, over a constraint that did nothing in it.
|
|
306
|
+
*
|
|
307
|
+
* ⚠️ A component that is present and NOT a number is not inert: the parser takes
|
|
308
|
+
* it as written and `"0.5" > 0` is true in the runtime's comparison, so such a
|
|
309
|
+
* constraint is carried and rigc's own parser says what is wrong with it.
|
|
310
|
+
*/
|
|
311
|
+
function inertPhysics(root: JsonObject): Map<string, string[]> {
|
|
312
|
+
const out = new Map<string, string[]>();
|
|
313
|
+
for (const raw of arr(root.constraints)) {
|
|
314
|
+
const constraint = obj(raw);
|
|
315
|
+
if (constraint.type !== 'physics' || typeof constraint.name !== 'string') continue;
|
|
316
|
+
const stated = PHYSICS_COMPONENTS.filter((field) => constraint[field] !== undefined);
|
|
317
|
+
if (!stated.every((field) => typeof constraint[field] === 'number' && !((constraint[field] as number) > 0))) continue;
|
|
318
|
+
out.set(
|
|
319
|
+
constraint.name,
|
|
320
|
+
stated.map((field) => `${field} ${String(constraint[field])}`),
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** What omitting one inert physics constraint took with it, for its finding. */
|
|
327
|
+
interface PhysicsOmission {
|
|
328
|
+
/** `animation "<a>" <timeline>`, in file order. */
|
|
329
|
+
timelines: string[];
|
|
330
|
+
/** The skins whose `physics` member list named it. */
|
|
331
|
+
skins: string[];
|
|
332
|
+
/** Per animation, the latest key time on an omitted timeline of this constraint. */
|
|
333
|
+
lastKeys: Map<string, number>;
|
|
334
|
+
}
|
|
335
|
+
|
|
295
336
|
/**
|
|
296
337
|
* The per-skin member lists (`SkeletonJson` reads them before `attachments`).
|
|
297
338
|
*
|
|
@@ -446,8 +487,11 @@ const SLOT_TRACKS = Object.keys(EMITTED_SLOT_TRACKS);
|
|
|
446
487
|
|
|
447
488
|
/**
|
|
448
489
|
* The slot timelines the FORMAT has and the motion spec has no track for —
|
|
449
|
-
* `rgb`, `alpha` and `rgb2
|
|
450
|
-
*
|
|
490
|
+
* **none** since issue #730 spelled `rgb`, `alpha` and `rgb2`, which were the
|
|
491
|
+
* last three. It stays derived rather than deleted, because an empty
|
|
492
|
+
* difference of two tables is a measurement and a deleted one is a claim: the
|
|
493
|
+
* day the format grows a seventh slot timeline, this is where it appears, and
|
|
494
|
+
* the blocker's sentence names it without an edit.
|
|
451
495
|
*
|
|
452
496
|
* ⭐ Both sides are derived, and that is the whole reason it exists rather than
|
|
453
497
|
* being spelled into the blocker's sentence. `docs/INGEST.md`'s row for this
|
|
@@ -588,6 +632,19 @@ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
|
|
|
588
632
|
// declares no stage; when it does, this is the one place that changes.
|
|
589
633
|
const rigHeader = ingestHeader(obj(root.skeleton), opts, note);
|
|
590
634
|
|
|
635
|
+
// -- physics constraints that drive nothing (issue #731) ------------------
|
|
636
|
+
// Read before the skins, because a skin's `physics` member list is one of the
|
|
637
|
+
// three places such a constraint is named and each has to let go of it.
|
|
638
|
+
const inert = inertPhysics(root);
|
|
639
|
+
const omissions = new Map<string, PhysicsOmission>();
|
|
640
|
+
const omission = (name: string): PhysicsOmission => {
|
|
641
|
+
const found = omissions.get(name);
|
|
642
|
+
if (found !== undefined) return found;
|
|
643
|
+
const made: PhysicsOmission = { timelines: [], skins: [], lastKeys: new Map() };
|
|
644
|
+
omissions.set(name, made);
|
|
645
|
+
return made;
|
|
646
|
+
};
|
|
647
|
+
|
|
591
648
|
// -- bones ----------------------------------------------------------------
|
|
592
649
|
// Inverts `buildBone`, which copies every declared field and omits the rest.
|
|
593
650
|
const bones = arr(root.bones).map((raw) => {
|
|
@@ -645,7 +702,17 @@ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
|
|
|
645
702
|
let anyList = false;
|
|
646
703
|
for (const list of SKIN_LISTS) {
|
|
647
704
|
if (entry[list] !== undefined) {
|
|
648
|
-
|
|
705
|
+
let members = entry[list];
|
|
706
|
+
if (list === 'physics' && Array.isArray(members)) {
|
|
707
|
+
const named = members.filter((member): member is string => typeof member === 'string' && inert.has(member));
|
|
708
|
+
for (const member of named) omission(member).skins.push(nameOf(entry));
|
|
709
|
+
members = members.filter((member) => !(typeof member === 'string' && inert.has(member)));
|
|
710
|
+
// A list that named nothing BUT omitted constraints goes with them: an
|
|
711
|
+
// empty list and an absent one read the same, and only one of them is
|
|
712
|
+
// what the rebuild writes for a skin with no physics member.
|
|
713
|
+
if (named.length > 0 && (members as unknown[]).length === 0) continue;
|
|
714
|
+
}
|
|
715
|
+
lists[list] = members;
|
|
649
716
|
anyList = true;
|
|
650
717
|
}
|
|
651
718
|
}
|
|
@@ -669,6 +736,9 @@ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
|
|
|
669
736
|
note('blocker', 'CONSTRAINT_TYPE', who, `type ${JSON.stringify(constraint.type)} is not one of ${Object.keys(CONSTRAINT_FIELDS).join(', ')}`);
|
|
670
737
|
continue;
|
|
671
738
|
}
|
|
739
|
+
// Omitted rather than carried, and said below once the timelines it takes
|
|
740
|
+
// with it are known (`PHYSICS_DRIVES_NOTHING`).
|
|
741
|
+
if (type === 'physics' && typeof constraint.name === 'string' && inert.has(constraint.name)) continue;
|
|
672
742
|
const out: JsonObject = { name: constraint.name, type };
|
|
673
743
|
for (const field of fields) if (constraint[field] !== undefined) out[field] = constraint[field];
|
|
674
744
|
for (const key of Object.keys(constraint)) {
|
|
@@ -681,7 +751,43 @@ export function ingest(skeleton: unknown, opts: IngestOptions): IngestResult {
|
|
|
681
751
|
// -- animations -----------------------------------------------------------
|
|
682
752
|
const animations: JsonObject = {};
|
|
683
753
|
for (const [animName, raw] of objEntries(root.animations)) {
|
|
684
|
-
animations[animName] = ingestAnimation(animName, raw, root, note)
|
|
754
|
+
animations[animName] = ingestAnimation(animName, raw, root, note, inert, (name, property, lastKey) => {
|
|
755
|
+
const one = omission(name);
|
|
756
|
+
one.timelines.push(`animation "${animName}" ${property}`);
|
|
757
|
+
one.lastKeys.set(animName, Math.max(one.lastKeys.get(animName) ?? 0, lastKey));
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// One finding per inert constraint, in the file's own order, naming what went
|
|
762
|
+
// with it. ⚠️ An animation's duration is the largest key time it has LEFT, so
|
|
763
|
+
// an omitted timeline that held the last key shortens the rebuilt animation —
|
|
764
|
+
// the one place this omission is not a no-op, and the detail says so there.
|
|
765
|
+
for (const [name, stated] of inert) {
|
|
766
|
+
const gone = omission(name);
|
|
767
|
+
const shortened = [...gone.lastKeys]
|
|
768
|
+
.map(([animName, lastKey]) => [animName, lastKey, Number(obj(animations[animName]).duration)] as const)
|
|
769
|
+
.filter(([, lastKey, duration]) => lastKey > duration)
|
|
770
|
+
.map(
|
|
771
|
+
([animName, lastKey, duration]) =>
|
|
772
|
+
`animation "${animName}" had its last key at ${lastKey}s on one of them, so the rebuilt animation ends at ${duration}s`,
|
|
773
|
+
);
|
|
774
|
+
note(
|
|
775
|
+
'lossy',
|
|
776
|
+
'PHYSICS_DRIVES_NOTHING',
|
|
777
|
+
`constraint "${name}" (physics)`,
|
|
778
|
+
`drives no component: ${PHYSICS_COMPONENTS.join(', ')} are ` +
|
|
779
|
+
(stated.length ? `absent or at most 0 (it states ${stated.join(', ')})` : 'all absent') +
|
|
780
|
+
', and `PhysicsConstraint.update` applies one only above 0, so it moves no bone and `build` would refuse it ' +
|
|
781
|
+
'by name (A23_PHYSICS_CONSTRAINT_EFFECTIVE). The rig spec omits it' +
|
|
782
|
+
(gone.timelines.length
|
|
783
|
+
? `, and with it the ${gone.timelines.length} timeline(s) keyed to it, which would name a constraint the ` +
|
|
784
|
+
`rebuild does not have: ${gone.timelines.join('; ')}`
|
|
785
|
+
: '; no timeline keys it') +
|
|
786
|
+
(gone.skins.length ? `; it is taken off the physics list of skin(s) ${gone.skins.map((skin) => `"${skin}"`).join(', ')}` : '') +
|
|
787
|
+
(shortened.length
|
|
788
|
+
? `. One thing does move, because a duration is the last key an animation has left: ${shortened.join('; ')}`
|
|
789
|
+
: '. The rebuild differs from the source by exactly these no-ops'),
|
|
790
|
+
);
|
|
685
791
|
}
|
|
686
792
|
|
|
687
793
|
// -- assemble -------------------------------------------------------------
|
|
@@ -1053,6 +1159,53 @@ function ingestAttachment(
|
|
|
1053
1159
|
const type = att.type === undefined ? 'region' : String(att.type);
|
|
1054
1160
|
const out: JsonObject = {};
|
|
1055
1161
|
|
|
1162
|
+
// A LINKED mesh, in either of the format's two spellings. The test is the
|
|
1163
|
+
// parser's own — one branch for `mesh` and `linkedmesh`, and a truthy `source`
|
|
1164
|
+
// decides (`SkeletonJson.js:568-569`, `:582`) — so `type: "mesh"` carrying
|
|
1165
|
+
// `source` inverts to a link, and a `linkedmesh` with none does NOT: that map
|
|
1166
|
+
// is read as an ordinary mesh, whose `uvs` it does not have, and the parser
|
|
1167
|
+
// throws on it. Reading the second as a link would be this module inventing a
|
|
1168
|
+
// construct the file does not contain (issue #691).
|
|
1169
|
+
const linked = (type === 'mesh' || type === 'linkedmesh') && typeof att.source === 'string' && att.source.length > 0;
|
|
1170
|
+
|
|
1171
|
+
// The three types the parser gives a texture `path` to, which is exactly the
|
|
1172
|
+
// three that call `carryArt` below. `readAttachment` reaches
|
|
1173
|
+
// `getValue(map, "path", name)` in the `region` branch (`SkeletonJson.js:529`)
|
|
1174
|
+
// and in the shared `mesh`/`linkedmesh` branch (`:560`); `boundingbox`,
|
|
1175
|
+
// `path`, `point` and `clipping` are constructed from the name alone and
|
|
1176
|
+
// resolve no region at all.
|
|
1177
|
+
const resolvesRegion = linked || type === 'region' || type === 'mesh';
|
|
1178
|
+
|
|
1179
|
+
/**
|
|
1180
|
+
* The atlas region a dropped `name` was carrying, which the rebuild has to
|
|
1181
|
+
* state as `path` or lose.
|
|
1182
|
+
*
|
|
1183
|
+
* 🚨 **The name is not bookkeeping — it is the region key** (issue #742).
|
|
1184
|
+
* `readAttachment` reads `name = getValue(map, "name", placeholder)` and then
|
|
1185
|
+
* `path = getValue(map, "path", name)`, so `path` defaults to the NAME and not
|
|
1186
|
+
* to the placeholder. A source that states a `name` and no `path` therefore
|
|
1187
|
+
* draws the region that name spells; rigc drops the name (the rig spec has no
|
|
1188
|
+
* field for one — see below) and used to write no `path` either, so the
|
|
1189
|
+
* rebuild asked the atlas for the PLACEHOLDER. Measured on a forged export
|
|
1190
|
+
* whose three attachments state `name` and no `path`: the source loads against
|
|
1191
|
+
* its own pack and the rebuild failed `A08_REGION_NAMES_MATCH_ATTACHMENTS`
|
|
1192
|
+
* three times and `A00_ROUNDTRIP_PARSE` with it.
|
|
1193
|
+
*
|
|
1194
|
+
* ⚠️ A name EQUAL to the placeholder resolves the same region either way, so
|
|
1195
|
+
* nothing is written for it — the rebuild would otherwise spell a field the
|
|
1196
|
+
* source did not, and `A18`'s reference is the source file.
|
|
1197
|
+
*
|
|
1198
|
+
* 🔑 This is not `at.contested`'s business, and that is the half the card
|
|
1199
|
+
* nearly missed. Where a placeholder is contested rigc composes a name of its
|
|
1200
|
+
* own and `nameSkinAttachment` pins `path` at the PLACEHOLDER — so a contested
|
|
1201
|
+
* source naming its own regions lost them with no finding at all, which is
|
|
1202
|
+
* quieter than the uncontested case rather than safer.
|
|
1203
|
+
*/
|
|
1204
|
+
const keptPath =
|
|
1205
|
+
att.path === undefined && resolvesRegion && typeof att.name === 'string' && att.name !== placeholder
|
|
1206
|
+
? att.name
|
|
1207
|
+
: undefined;
|
|
1208
|
+
|
|
1056
1209
|
// `name` is DERIVED by rigc — `composeSkinAttachmentName` writes one exactly
|
|
1057
1210
|
// where a placeholder is contested — so the rig spec has no field for it and
|
|
1058
1211
|
// it is dropped. Where the contest survives the round trip the same name comes
|
|
@@ -1063,17 +1216,34 @@ function ingestAttachment(
|
|
|
1063
1216
|
'ATTACHMENT_NAME',
|
|
1064
1217
|
at.where,
|
|
1065
1218
|
`the attachment states name ${JSON.stringify(att.name)} and only ONE skin fills this placeholder, so rigc ` +
|
|
1066
|
-
'writes no name on the rebuild — it composes "<skin>/<placeholder>" only for a contested placeholder (#541)'
|
|
1219
|
+
'writes no name on the rebuild — it composes "<skin>/<placeholder>" only for a contested placeholder (#541). ' +
|
|
1220
|
+
(keptPath !== undefined
|
|
1221
|
+
? `That name was also the ATLAS REGION this attachment resolves, because \`path\` defaults to the name and ` +
|
|
1222
|
+
`not to the placeholder (\`SkeletonJson.js:526\`, \`:529\`, \`:560\`) — so the region key is KEPT as ` +
|
|
1223
|
+
`"path": ${JSON.stringify(keptPath)} and the name alone is lost`
|
|
1224
|
+
: att.path !== undefined
|
|
1225
|
+
? `The region key is the source's own \`path\` ${JSON.stringify(att.path)}, carried unchanged, so the ` +
|
|
1226
|
+
'name alone is lost'
|
|
1227
|
+
: resolvesRegion
|
|
1228
|
+
? 'The name is the placeholder itself, so the rebuild resolves the same atlas region and no `path` is ' +
|
|
1229
|
+
'written for it'
|
|
1230
|
+
: `An attachment of type ${JSON.stringify(type)} resolves no atlas region — the parser builds it from ` +
|
|
1231
|
+
'the name alone — so there is no region key to keep and the name is the whole loss'),
|
|
1067
1232
|
);
|
|
1068
1233
|
}
|
|
1069
1234
|
|
|
1070
1235
|
/** The texture side, which the skeleton does not encode. Inverts `buildRigRegion`'s tail. */
|
|
1071
1236
|
const carryArt = (): void => {
|
|
1072
1237
|
if (att.path !== undefined) out.path = att.path;
|
|
1238
|
+
else if (keptPath !== undefined) out.path = keptPath;
|
|
1073
1239
|
// `buildRigRegion` writes `path` when the image basename differs from the
|
|
1074
|
-
// placeholder, so naming the image after
|
|
1075
|
-
// the same `path` decision AND the same atlas region name.
|
|
1076
|
-
|
|
1240
|
+
// placeholder, so naming the image after the region this attachment RESOLVES
|
|
1241
|
+
// reproduces the same `path` decision AND the same atlas region name. Read
|
|
1242
|
+
// off `out.path` rather than off `att.path`, so that the region a dropped
|
|
1243
|
+
// `name` was carrying names the loose PNG too — otherwise `path` and `image`
|
|
1244
|
+
// would disagree and `attachmentPath` would emit the first while the atlas
|
|
1245
|
+
// was packed under the second.
|
|
1246
|
+
if (opts.art === 'loose') out.image = `${out.path === undefined ? placeholder : String(out.path)}.png`;
|
|
1077
1247
|
if (att.width !== undefined) out.width = att.width;
|
|
1078
1248
|
if (att.height !== undefined) out.height = att.height;
|
|
1079
1249
|
};
|
|
@@ -1094,15 +1264,6 @@ function ingestAttachment(
|
|
|
1094
1264
|
|
|
1095
1265
|
const vertexCount = typeof att.vertexCount === 'number' ? att.vertexCount : undefined;
|
|
1096
1266
|
|
|
1097
|
-
// A LINKED mesh, in either of the format's two spellings. The test is the
|
|
1098
|
-
// parser's own — one branch for `mesh` and `linkedmesh`, and a truthy `source`
|
|
1099
|
-
// decides (`SkeletonJson.js:568-569`, `:582`) — so `type: "mesh"` carrying
|
|
1100
|
-
// `source` inverts to a link, and a `linkedmesh` with none does NOT: that map
|
|
1101
|
-
// is read as an ordinary mesh, whose `uvs` it does not have, and the parser
|
|
1102
|
-
// throws on it. Reading the second as a link would be this module inventing a
|
|
1103
|
-
// construct the file does not contain (issue #691).
|
|
1104
|
-
const linked = (type === 'mesh' || type === 'linkedmesh') && typeof att.source === 'string' && att.source.length > 0;
|
|
1105
|
-
|
|
1106
1267
|
if (linked) {
|
|
1107
1268
|
out.type = 'linkedmesh';
|
|
1108
1269
|
carryArt();
|
|
@@ -1192,7 +1353,14 @@ function ingestAttachment(
|
|
|
1192
1353
|
}
|
|
1193
1354
|
|
|
1194
1355
|
/** One animation. Inverts step 5 of `compile()` — the whole timeline half. */
|
|
1195
|
-
function ingestAnimation(
|
|
1356
|
+
function ingestAnimation(
|
|
1357
|
+
animName: string,
|
|
1358
|
+
anim: JsonObject,
|
|
1359
|
+
root: JsonObject,
|
|
1360
|
+
note: Note,
|
|
1361
|
+
inert: ReadonlyMap<string, unknown>,
|
|
1362
|
+
omitted: (constraint: string, property: string, lastKey: number) => void,
|
|
1363
|
+
): JsonObject {
|
|
1196
1364
|
const tracks: JsonObject[] = [];
|
|
1197
1365
|
let maxT = 0;
|
|
1198
1366
|
const seeT = (t: number): void => {
|
|
@@ -1283,6 +1451,20 @@ function ingestAnimation(animName: string, anim: JsonObject, root: JsonObject, n
|
|
|
1283
1451
|
const family = (group: 'path' | 'physics' | 'slider', shapes: Record<string, TrackShape>): void => {
|
|
1284
1452
|
for (const [name, timelines] of objEntries(anim[group])) {
|
|
1285
1453
|
for (const [property, keys] of arrEntries(timelines)) {
|
|
1454
|
+
// A timeline keyed to a physics constraint `ingest` omitted (issue #731)
|
|
1455
|
+
// goes with it: carried, it names a constraint the rebuilt rig has not
|
|
1456
|
+
// got, and `build` refuses the whole motion spec over it. Its keys are
|
|
1457
|
+
// not seen by `seeT` — they are not in the rebuild — and their latest
|
|
1458
|
+
// time is handed back so the finding can say when that shortened one.
|
|
1459
|
+
if (group === 'physics' && inert.has(name)) {
|
|
1460
|
+
let lastKey = 0;
|
|
1461
|
+
for (const raw of keys) {
|
|
1462
|
+
const t = obj(raw).time;
|
|
1463
|
+
if (typeof t === 'number' && t > lastKey) lastKey = t;
|
|
1464
|
+
}
|
|
1465
|
+
omitted(name, property, lastKey);
|
|
1466
|
+
continue;
|
|
1467
|
+
}
|
|
1286
1468
|
const shape = shapes[property];
|
|
1287
1469
|
const where = `animation "${animName}" ${group} "${name}" ${property}`;
|
|
1288
1470
|
if (shape === undefined) {
|
|
@@ -1358,14 +1540,58 @@ function ingestAnimation(animName: string, anim: JsonObject, root: JsonObject, n
|
|
|
1358
1540
|
return entry;
|
|
1359
1541
|
}),
|
|
1360
1542
|
});
|
|
1543
|
+
} else if (property === 'alpha') {
|
|
1544
|
+
// Inverts `compileTrack`'s alpha branch, whose key is `{time, value}` —
|
|
1545
|
+
// the one colour shape the format stores as a number, read by
|
|
1546
|
+
// `readTimeline1` with a per-key default of **0**. That is a value
|
|
1547
|
+
// track's shape exactly, so it goes through the same inversion and
|
|
1548
|
+
// inherits its two findings: an omitted `value` is written out at 0 and
|
|
1549
|
+
// reported as `TIMELINE_KEY_RESTATED`, and a field the shape has no
|
|
1550
|
+
// place for is a `TIMELINE_FIELD` blocker (issue #730).
|
|
1551
|
+
valueTrack({ slot }, property, keys, [['value', 0]], where);
|
|
1552
|
+
} else if (property === 'rgb' || property === 'rgb2') {
|
|
1553
|
+
// Inverts `compileTrack`'s other two separable shapes (issue #730):
|
|
1554
|
+
// `rgb` is `{time, color: "rrggbb"}` and `rgb2` is `{time, light:
|
|
1555
|
+
// "rrggbb", dark: "rrggbb"}`, and the spec's `v` is their channels in
|
|
1556
|
+
// `readCurve`'s order. Each keeps its own name and its own key times:
|
|
1557
|
+
// folding an `rgb` and an `alpha` into one `rgba` would state each
|
|
1558
|
+
// channel at the other's key times, a value nobody keyed.
|
|
1559
|
+
tracks.push({
|
|
1560
|
+
slot,
|
|
1561
|
+
property,
|
|
1562
|
+
keys: keys.map((raw) => {
|
|
1563
|
+
const key = obj(raw);
|
|
1564
|
+
const v =
|
|
1565
|
+
property === 'rgb'
|
|
1566
|
+
? hexToRgb(String(key.color))
|
|
1567
|
+
: [...hexToRgb(String(key.light)), ...hexToRgb(String(key.dark))];
|
|
1568
|
+
const entry: JsonObject = { t: timeOf(key), v };
|
|
1569
|
+
easing(key, entry);
|
|
1570
|
+
return entry;
|
|
1571
|
+
}),
|
|
1572
|
+
});
|
|
1361
1573
|
} else {
|
|
1574
|
+
// 🔒 Composed from both tables, so it says the true thing whichever of
|
|
1575
|
+
// two states it is reached in. A name the FORMAT has and the spec does
|
|
1576
|
+
// not is the first; since issue #730 carried the last three there is no
|
|
1577
|
+
// such name (`UNSPELT_SLOT_TRACKS` is empty), and what still reaches
|
|
1578
|
+
// here is a name the format does not have at all — which
|
|
1579
|
+
// `SkeletonJson.readAnimation` throws on (`Invalid timeline type for a
|
|
1580
|
+
// slot`), so the sentence says so instead of printing an empty
|
|
1581
|
+
// "remaining" list about a file no runtime loads.
|
|
1582
|
+
const inFormat = property in CHANNELS_BY_KIND.slot;
|
|
1362
1583
|
note(
|
|
1363
1584
|
'blocker',
|
|
1364
1585
|
'SLOT_TIMELINE',
|
|
1365
1586
|
where,
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
`
|
|
1587
|
+
(inFormat
|
|
1588
|
+
? `timeline "${property}" is in the format and the motion spec has no track for it`
|
|
1589
|
+
: `timeline "${property}" is not a slot timeline the format has — the runtime's reader throws ` +
|
|
1590
|
+
'"Invalid timeline type for a slot" on it, so no player loads this file') +
|
|
1591
|
+
` — a slot track is ${SLOT_TRACKS.join(' or ')} and nothing else, so the rebuild plays nothing here` +
|
|
1592
|
+
(UNSPELT_SLOT_TRACKS.length > 0
|
|
1593
|
+
? `. The format's remaining slot timelines are ${UNSPELT_SLOT_TRACKS.join(', ')}`
|
|
1594
|
+
: ''),
|
|
1369
1595
|
);
|
|
1370
1596
|
}
|
|
1371
1597
|
}
|
package/src/png.ts
CHANGED
|
@@ -89,15 +89,170 @@ function scanForTrns(buf: Buffer): boolean {
|
|
|
89
89
|
return false;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
92
|
+
function hexBytes(bytes: Uint8Array): string {
|
|
93
|
+
return [...bytes].map((b) => b.toString(16).toUpperCase().padStart(2, '0')).join(' ');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The signature every PNG begins with, as the messages below print it. */
|
|
97
|
+
const PNG_SIGNATURE_HEX = hexBytes(Uint8Array.from(SIGNATURE));
|
|
98
|
+
|
|
99
|
+
function startsWith(buf: Uint8Array, at: number, bytes: ReadonlyArray<number>): boolean {
|
|
100
|
+
if (buf.length < at + bytes.length) return false;
|
|
101
|
+
for (let i = 0; i < bytes.length; i++) if (buf[at + i] !== bytes[i]) return false;
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const ascii = (text: string): number[] => [...text].map((c) => c.charCodeAt(0));
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The image formats a page file is found to be in instead, by their own
|
|
109
|
+
* signatures. Each entry is the format's fixed leading bytes and nothing
|
|
110
|
+
* inferred from them — rigc decodes none of these, so the one honest thing to
|
|
111
|
+
* say about such a file is what its first bytes are.
|
|
112
|
+
*
|
|
113
|
+
* ⭐ Named at all because a file's NAME is not evidence (issue #732): a
|
|
114
|
+
* production pack shipped WebP pages called `*.png`, and "bad signature" sent
|
|
115
|
+
* the author to look for corruption in a well-formed image of another format.
|
|
116
|
+
*/
|
|
117
|
+
const OTHER_FORMATS: ReadonlyArray<{ name: string; matches: (buf: Uint8Array) => boolean }> = [
|
|
118
|
+
// A RIFF container with the form type at byte 8. The first chunk's FourCC
|
|
119
|
+
// (`VP8 `, `VP8L`, `VP8X`) is printed beside it by `whatItIs`, never read.
|
|
120
|
+
{ name: 'WebP', matches: (buf) => startsWith(buf, 0, ascii('RIFF')) && startsWith(buf, 8, ascii('WEBP')) },
|
|
121
|
+
{ name: 'JPEG', matches: (buf) => startsWith(buf, 0, [0xff, 0xd8, 0xff]) },
|
|
122
|
+
{ name: 'GIF', matches: (buf) => startsWith(buf, 0, ascii('GIF87a')) || startsWith(buf, 0, ascii('GIF89a')) },
|
|
123
|
+
{ name: 'KTX', matches: (buf) => startsWith(buf, 0, [0xab, ...ascii('KTX 11'), 0xbb, 0x0d, 0x0a, 0x1a, 0x0a]) },
|
|
124
|
+
{ name: 'KTX2', matches: (buf) => startsWith(buf, 0, [0xab, ...ascii('KTX 20'), 0xbb, 0x0d, 0x0a, 0x1a, 0x0a]) },
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
/** What a file that does not begin with the PNG signature is, as a phrase. */
|
|
128
|
+
function whatItIs(buf: Uint8Array): string {
|
|
129
|
+
const found = OTHER_FORMATS.find((format) => format.matches(buf));
|
|
130
|
+
if (found === undefined) {
|
|
131
|
+
return (
|
|
132
|
+
'in no image format rigc recognises by its signature (it names ' +
|
|
133
|
+
`${OTHER_FORMATS.map((format) => format.name).join(', ')} when it meets them)`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (found.name === 'WebP' && buf.length >= 16) {
|
|
137
|
+
const chunk = String.fromCharCode(buf[12], buf[13], buf[14], buf[15]);
|
|
138
|
+
return `a WebP image (a RIFF/WEBP container whose first chunk is ${JSON.stringify(chunk)})`;
|
|
97
139
|
}
|
|
98
|
-
|
|
99
|
-
|
|
140
|
+
return `a ${found.name} image`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Why the bytes at `path` are not a PNG rigc can read, as one sentence — or
|
|
145
|
+
* `null` when they are.
|
|
146
|
+
*
|
|
147
|
+
* 🔒 **The one reader of a page file's identity** (issue #732). Every reader of
|
|
148
|
+
* a page goes through it: `readPngInfo` and `readPngHeader` below (the size
|
|
149
|
+
* `A06` judges, the alpha `A19` judges on a one-part page, the sizes the
|
|
150
|
+
* compiler takes from a loose part or a pack's page) and `readPlate` in
|
|
151
|
+
* [`tools/plate.ts`](../tools/plate.ts) (the renderer, `A19`'s scan of a shared
|
|
152
|
+
* page, the region lift and the packer). Before it, one WebP page reached the
|
|
153
|
+
* gate as `A06`'s `threw: not a PNG (bad signature)` and `A19`'s `threw: cannot
|
|
154
|
+
* decode PNG …: unexpected end of file`, and reached `build --atlas-in` as a
|
|
155
|
+
* stack trace — three sentences about one file, and none said what it was.
|
|
156
|
+
*
|
|
157
|
+
* It says three things and decodes nothing: what the file is instead (by the
|
|
158
|
+
* signature it does carry, with its first bytes in hex either way), whether a
|
|
159
|
+
* file that begins as a PNG ends before its chunks do, and whether IHDR comes
|
|
160
|
+
* first. The chunk walk reads only the eight-byte chunk headers, so it costs a
|
|
161
|
+
* skip through the file and no inflate.
|
|
162
|
+
*
|
|
163
|
+
* ⚠️ **"Truncated" is judged by the chunk walk, not by a length floor.** The
|
|
164
|
+
* floor that stood here (`buf.length < 26`, "too short") never said truncated,
|
|
165
|
+
* and a PNG cut anywhere after its IHDR passed it: measured on a packed page
|
|
166
|
+
* cut to 60 bytes, the default profile built it green and wrote a skeleton
|
|
167
|
+
* whose page no reader could decode. A PNG ends at its IEND chunk, so a file
|
|
168
|
+
* that runs out first is truncated wherever it runs out.
|
|
169
|
+
*/
|
|
170
|
+
export function pngProblem(buf: Uint8Array, path: string): string | null {
|
|
171
|
+
if (buf.length === 0) return `${path} is empty (0 bytes), and a PNG's first 8 bytes are ${PNG_SIGNATURE_HEX}`;
|
|
172
|
+
if (!startsWith(buf, 0, SIGNATURE.slice(0, Math.min(buf.length, SIGNATURE.length)))) {
|
|
173
|
+
const shown = buf.subarray(0, 12);
|
|
174
|
+
const byName = /\.png$/i.test(path) ? '; its name ends in .png, and it is the bytes that decide what a file is' : '';
|
|
175
|
+
return (
|
|
176
|
+
`${path} is ${whatItIs(buf)}, not a PNG: its first ${shown.length} byte(s) are ${hexBytes(shown)}, where a ` +
|
|
177
|
+
`PNG's first 8 are ${PNG_SIGNATURE_HEX}${byName}. rigc reads PNG and nothing else — its page-size and alpha ` +
|
|
178
|
+
'readers, its renderer and its region lift all decode PNG, and it links no decoder for any other format — ' +
|
|
179
|
+
'so nothing in this file was measured. Re-export it as PNG'
|
|
180
|
+
);
|
|
100
181
|
}
|
|
182
|
+
const truncated = (where: string): string =>
|
|
183
|
+
`${path} is a truncated PNG: it is ${buf.length} byte(s) long and ${where}, so no reader can decode it. ` +
|
|
184
|
+
'Re-export or re-copy the file whole';
|
|
185
|
+
if (buf.length < SIGNATURE.length) return truncated(`ends ${buf.length} byte(s) into the 8-byte PNG signature`);
|
|
186
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
187
|
+
let at = SIGNATURE.length;
|
|
188
|
+
let previous = 'the signature';
|
|
189
|
+
for (;;) {
|
|
190
|
+
if (at + 8 > buf.length) {
|
|
191
|
+
return truncated(
|
|
192
|
+
at === buf.length
|
|
193
|
+
? `ends after ${previous} with no IEND chunk`
|
|
194
|
+
: `ends ${buf.length - at} byte(s) into the header of the chunk after ${previous}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
const length = view.getUint32(at);
|
|
198
|
+
const type = String.fromCharCode(buf[at + 4], buf[at + 5], buf[at + 6], buf[at + 7]);
|
|
199
|
+
if (at === SIGNATURE.length && type !== 'IHDR') {
|
|
200
|
+
return `${path} is a PNG whose first chunk is ${JSON.stringify(type)} rather than the IHDR the format requires first`;
|
|
201
|
+
}
|
|
202
|
+
if (type === 'IHDR' && length !== 13) {
|
|
203
|
+
return `${path} is a PNG whose IHDR chunk declares ${length} byte(s) of data, where the format's is 13`;
|
|
204
|
+
}
|
|
205
|
+
const next = at + 12 + length; // 4 length + 4 type + body + 4 CRC
|
|
206
|
+
if (next > buf.length) {
|
|
207
|
+
return truncated(
|
|
208
|
+
`its ${type} chunk at byte ${at} declares ${length} byte(s) of data, which with its CRC runs to byte ${next}`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
if (type === 'IEND') return null;
|
|
212
|
+
previous = `its ${type} chunk at byte ${at}`;
|
|
213
|
+
at = next;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* A file that is not a PNG rigc can read, thrown by the readers that return a
|
|
219
|
+
* value or nothing (`readPngInfo`, `readPlate`). The message is `pngProblem`'s,
|
|
220
|
+
* whole; a caller that owns a named failure — the gate, a `CompileError` —
|
|
221
|
+
* reads the sentence instead of catching the throw (`readPngHeader`).
|
|
222
|
+
*/
|
|
223
|
+
export class NotAPngError extends Error {
|
|
224
|
+
readonly path: string;
|
|
225
|
+
constructor(path: string, message: string) {
|
|
226
|
+
super(message);
|
|
227
|
+
this.name = 'NotAPngError';
|
|
228
|
+
this.path = path;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** `pngProblem` as a refusal, for the readers that return a value or throw. */
|
|
233
|
+
export function assertPng(buf: Uint8Array, path: string): void {
|
|
234
|
+
const problem = pngProblem(buf, path);
|
|
235
|
+
if (problem !== null) throw new NotAPngError(path, problem);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* A page's header, or the sentence that says why it has none — for a caller
|
|
240
|
+
* whose failures are named rather than thrown.
|
|
241
|
+
*/
|
|
242
|
+
export function readPngHeader(path: string): { info: PngInfo; problem: null } | { info: null; problem: string } {
|
|
243
|
+
const buf = readFileSync(path);
|
|
244
|
+
const problem = pngProblem(buf, path);
|
|
245
|
+
return problem === null ? { info: headerOf(buf), problem: null } : { info: null, problem };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function readPngInfo(path: string): PngInfo {
|
|
249
|
+
const buf = readFileSync(path);
|
|
250
|
+
assertPng(buf, path);
|
|
251
|
+
return headerOf(buf);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The IHDR fields of a file `pngProblem` has already accepted. */
|
|
255
|
+
function headerOf(buf: Buffer): PngInfo {
|
|
101
256
|
const colourType = buf.readUInt8(25);
|
|
102
257
|
const hasAlpha = COLOUR_TYPE_HAS_ALPHA[colourType] ?? false;
|
|
103
258
|
// A file with an alpha channel cannot also carry tRNS, so the scan is skipped
|
package/src/rig.ts
CHANGED
|
@@ -1934,8 +1934,35 @@ export function parseRigSpec(raw: unknown, where: string): RigSpec {
|
|
|
1934
1934
|
// consumer's process, so they are refused here where the message can name the
|
|
1935
1935
|
// rig spec. What the parser does NOT check is the pairing with `skin: true`,
|
|
1936
1936
|
// and that half is silent in both directions (see `RigSkinEntry`).
|
|
1937
|
-
|
|
1938
|
-
|
|
1937
|
+
//
|
|
1938
|
+
// ⭐ **Sets rather than "which skin owns this", because a name may be in
|
|
1939
|
+
// several lists.** Until issue #725 these were `name -> the skin that claimed
|
|
1940
|
+
// it first`, and a second skin naming the same bone or constraint was refused
|
|
1941
|
+
// with *"a bone belongs to one skin"*. The rule was never the parser's and the
|
|
1942
|
+
// comment above it said so; what it rested on was that "which skin am I for"
|
|
1943
|
+
// has no answer for a name in two lists. It has one, and the runtime gives it:
|
|
1944
|
+
// `Skeleton.updateCache` activates the bones of the skin being WORN, so a bone
|
|
1945
|
+
// two mutually exclusive variants both list is active under either — measured
|
|
1946
|
+
// on a hand-forged file the refusal used to prevent, `bone.active` true under
|
|
1947
|
+
// each of the two skins, false under a third that lists nothing and false with
|
|
1948
|
+
// no skin set. `Skin.addSkin` deduplicates by object identity, so even a
|
|
1949
|
+
// consumer combining both variants gets the bone once. The format is a
|
|
1950
|
+
// per-skin SET and rigc now says the same thing.
|
|
1951
|
+
//
|
|
1952
|
+
// ⚠️ **The worn skin, and only the worn skin.** `updateCache` reads
|
|
1953
|
+
// `this.skin` and never `SkeletonData.defaultSkin` — the default-skin fallback
|
|
1954
|
+
// is `getAttachment`'s and covers art alone — so a `skin: true` bone that only
|
|
1955
|
+
// the `default` skin lists is measured INACTIVE under every other skin and
|
|
1956
|
+
// with no skin set. That is why nothing here is a union, and it is the one
|
|
1957
|
+
// shape an author is most likely to write expecting "always on".
|
|
1958
|
+
//
|
|
1959
|
+
// What the rule was really guarding — a second list that was meant to name a
|
|
1960
|
+
// different bone — is not derivable from the file, so it is not refused. Every
|
|
1961
|
+
// refusal that IS derivable stays: a name the rig does not declare, a name
|
|
1962
|
+
// declared under another constraint kind, and both halves of the `skin: true`
|
|
1963
|
+
// switch, each of which the rig suite now measures on a shared member.
|
|
1964
|
+
const skinBoneUse = new Set<string>();
|
|
1965
|
+
const skinConstraintUse = new Set<string>();
|
|
1939
1966
|
if (spec.skins !== undefined) {
|
|
1940
1967
|
if (!isObj(spec.skins)) throw new CompileError(`${where}: "skins" is an object keyed by skin name`);
|
|
1941
1968
|
const collision = RIG_SKIN_KEYS.find((key) => slotNames.has(key));
|
|
@@ -1956,17 +1983,7 @@ export function parseRigSpec(raw: unknown, where: string): RigSpec {
|
|
|
1956
1983
|
if (!seen.has(bone)) {
|
|
1957
1984
|
throw new CompileError(`${at} activates bone "${bone}", which this rig does not declare`);
|
|
1958
1985
|
}
|
|
1959
|
-
|
|
1960
|
-
if (previous !== undefined && previous !== skinName) {
|
|
1961
|
-
// Not a parser rule — a rig-spec one. `updateCache` activates the union
|
|
1962
|
-
// of the current skin's bones, so a bone in two skins is a bone whose
|
|
1963
|
-
// "which skin am I for" question has no answer, and the second list is
|
|
1964
|
-
// usually a copy-paste that was meant to be a different bone.
|
|
1965
|
-
throw new CompileError(
|
|
1966
|
-
`${at} activates bone "${bone}", which skin "${previous}" already activates; a bone belongs to one skin`,
|
|
1967
|
-
);
|
|
1968
|
-
}
|
|
1969
|
-
skinBoneUse.set(bone, skinName);
|
|
1986
|
+
skinBoneUse.add(bone);
|
|
1970
1987
|
const declared = spec.bones.find((b) => b.name === bone);
|
|
1971
1988
|
if (declared?.skin !== true) {
|
|
1972
1989
|
throw new CompileError(
|
|
@@ -1994,13 +2011,7 @@ export function parseRigSpec(raw: unknown, where: string): RigSpec {
|
|
|
1994
2011
|
'a skin looks its constraints up by name AND type, so this one is a miss and the loader throws',
|
|
1995
2012
|
);
|
|
1996
2013
|
}
|
|
1997
|
-
|
|
1998
|
-
if (previous !== undefined && previous !== skinName) {
|
|
1999
|
-
throw new CompileError(
|
|
2000
|
-
`${at} activates ${type} constraint "${name}", which skin "${previous}" already activates; a constraint belongs to one skin`,
|
|
2001
|
-
);
|
|
2002
|
-
}
|
|
2003
|
-
skinConstraintUse.set(constraintAt(type, name), skinName);
|
|
2014
|
+
skinConstraintUse.add(constraintAt(type, name));
|
|
2004
2015
|
if (!facts.skinRequired) {
|
|
2005
2016
|
throw new CompileError(
|
|
2006
2017
|
`${at} activates ${type} constraint "${name}", but that constraint does not declare \`"skin": true\`. ` +
|
|
@@ -2016,7 +2027,7 @@ export function parseRigSpec(raw: unknown, where: string): RigSpec {
|
|
|
2016
2027
|
// there is. Outside the block above on purpose — a rig with no `skins` at all
|
|
2017
2028
|
// is the strongest case of it. A listed bone activates its ancestors too
|
|
2018
2029
|
// (`Skeleton.ts:198-205`), so a parent reachable only that way is not dead.
|
|
2019
|
-
const activated = new Set(skinBoneUse
|
|
2030
|
+
const activated = new Set(skinBoneUse);
|
|
2020
2031
|
const parentOf = new Map(spec.bones.map((b) => [b.name, b.parent]));
|
|
2021
2032
|
for (const bone of [...activated]) {
|
|
2022
2033
|
for (let cursor = parentOf.get(bone); cursor; cursor = parentOf.get(cursor)) activated.add(cursor);
|