spine-rigc 0.29.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 +101 -25
- package/docs/INGEST.md +2 -1
- package/docs/SPEC_COVERAGE.md +4 -3
- package/package.json +1 -1
- package/src/compile.ts +173 -26
- package/src/ingest.ts +180 -9
- package/src/png.ts +162 -7
- package/src/timelines.ts +34 -0
- package/src/types.ts +1 -1
- package/src/validate.ts +256 -18
- package/tools/plate.ts +6 -0
package/src/compile.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
21
21
|
import { existsSync, readFileSync } from 'node:fs';
|
|
22
|
-
import { readPngInfo } from './png.ts';
|
|
22
|
+
import { readPngHeader, readPngInfo } from './png.ts';
|
|
23
23
|
import {
|
|
24
24
|
DEPTH_TONE_IDENTITY,
|
|
25
25
|
DepthError,
|
|
@@ -95,7 +95,14 @@ import {
|
|
|
95
95
|
type AtlasRegion,
|
|
96
96
|
type ParsedAtlas,
|
|
97
97
|
} from './atlas.ts';
|
|
98
|
-
import {
|
|
98
|
+
import {
|
|
99
|
+
KEY_TIME_EPSILON,
|
|
100
|
+
physicsKeyRefusal,
|
|
101
|
+
physicsRuleFor,
|
|
102
|
+
SLOT_COLOR_CHANNELS,
|
|
103
|
+
type PhysicsPoseRule,
|
|
104
|
+
type SlotColorChannel,
|
|
105
|
+
} from './timelines.ts';
|
|
99
106
|
import { evaluateDeformTransform } from './deformgen.ts';
|
|
100
107
|
import { evaluateTrackDerive, TRACK_DERIVE_PROJECTIONS, type TrackDeriveMember } from './trackgen.ts';
|
|
101
108
|
import {
|
|
@@ -783,6 +790,60 @@ function rgba2Hex(v: number[]): { light: string; dark: string } {
|
|
|
783
790
|
return { light: v.slice(0, 4).map(channelHex).join(''), dark: v.slice(4, 7).map(channelHex).join('') };
|
|
784
791
|
}
|
|
785
792
|
|
|
793
|
+
/** The five slot colour timelines, which are every `SLOT_TRACKS` shape but `attachment`. */
|
|
794
|
+
type SlotColourShape = 'rgba' | 'rgb' | 'alpha' | 'rgba2' | 'rgb2';
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* How one colour key is written, per timeline: how many channels its `v`
|
|
798
|
+
* carries, how the refusal spells that `v`, and the JSON fields the emitted key
|
|
799
|
+
* states them as.
|
|
800
|
+
*
|
|
801
|
+
* ⭐ **Every row is the parser's own reading, and the `v` is always the
|
|
802
|
+
* channels in the order `readCurve` indexes a curve array by** — which is what
|
|
803
|
+
* keeps a key and its raw curve parallel on every shape:
|
|
804
|
+
*
|
|
805
|
+
* - `rgba` → `color: "rrggbbaa"`, four channels (`RGBATimeline`, `frames << 2`).
|
|
806
|
+
* - `rgb` → `color: "rrggbb"`, three. `SkeletonJson`'s `rgb` branch reads the
|
|
807
|
+
* same `Color.fromString(keyMap.color)` and hands `setFrame` r, g and b alone,
|
|
808
|
+
* so an alpha pair written there would be read into a colour and dropped one
|
|
809
|
+
* line later — six digits is emitting what is read.
|
|
810
|
+
* - `alpha` → `value: a`, one channel, read by `readTimeline1` with a per-key
|
|
811
|
+
* default of **0**. It is a number rather than hex because the format stores
|
|
812
|
+
* it as one, so it is not quantised onto a byte the way the four hex shapes
|
|
813
|
+
* are.
|
|
814
|
+
* - `rgba2` → `light: "rrggbbaa"`, `dark: "rrggbb"`, seven (see `rgba2Hex`).
|
|
815
|
+
* - `rgb2` → `light: "rrggbb"`, `dark: "rrggbb"`, six: `RGB2Timeline.setFrame`
|
|
816
|
+
* takes r g b and r2 g2 b2, and neither colour's alpha.
|
|
817
|
+
*
|
|
818
|
+
* The count is checked before anything is written, with one sentence for all
|
|
819
|
+
* five (`rgba value needs 4 channels, got 3`), because `rgbaHex` and `rgba2Hex`
|
|
820
|
+
* already said exactly that for their two and a family whose members refuse in
|
|
821
|
+
* different words is a family an author has to learn twice.
|
|
822
|
+
*/
|
|
823
|
+
const COLOUR_KEYS: Record<SlotColourShape, { channels: number; spelling: string; write: (v: number[]) => Record<string, string | number> }> = {
|
|
824
|
+
rgba: { channels: 4, spelling: '[r,g,b,a]', write: (v) => ({ color: rgbaHex(v) }) },
|
|
825
|
+
rgb: { channels: 3, spelling: '[r,g,b]', write: (v) => ({ color: v.map(channelHex).join('') }) },
|
|
826
|
+
alpha: { channels: 1, spelling: '[a]', write: (v) => ({ value: r6(v[0]) }) },
|
|
827
|
+
rgba2: { channels: 7, spelling: '[lr,lg,lb,la,dr,dg,db]', write: (v) => rgba2Hex(v) },
|
|
828
|
+
rgb2: {
|
|
829
|
+
channels: 6,
|
|
830
|
+
spelling: '[lr,lg,lb,dr,dg,db]',
|
|
831
|
+
write: (v) => ({ light: v.slice(0, 3).map(channelHex).join(''), dark: v.slice(3, 6).map(channelHex).join('') }),
|
|
832
|
+
},
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
/** The colour of one key, as its timeline writes it — after the channel count the whole family refuses in one sentence. */
|
|
836
|
+
function colourKey(shape: SlotColourShape, v: number[]): Record<string, string | number> {
|
|
837
|
+
const { channels, write } = COLOUR_KEYS[shape];
|
|
838
|
+
if (v.length !== channels) {
|
|
839
|
+
throw new CompileError(`${shape} value needs ${channels} channel${channels === 1 ? '' : 's'}, got ${v.length}`);
|
|
840
|
+
}
|
|
841
|
+
return write(v);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** A slot colour channel as a sentence names it. */
|
|
845
|
+
const CHANNEL_WORDS: Record<SlotColorChannel, string> = { rgb: 'light rgb', alpha: 'alpha', dark: 'dark colour' };
|
|
846
|
+
|
|
786
847
|
/**
|
|
787
848
|
* One timeline a `MotionValueTrack` can name: the JSON fields a key carries, the
|
|
788
849
|
* per-key default the parser uses for each, and — where the runtime has one — the
|
|
@@ -940,8 +1001,18 @@ const SLIDER_TRACKS: Record<string, ValueTrackShape> = {
|
|
|
940
1001
|
* ⚠️ The KEY is the timeline name as the file carries it, and the VALUE names
|
|
941
1002
|
* the branch below that writes its keys — so an entry added here without a
|
|
942
1003
|
* branch to write it is an entry emitted in some other timeline's shape, which
|
|
943
|
-
* is the defect this table closed rather than a new affordance.
|
|
944
|
-
*
|
|
1004
|
+
* is the defect this table closed rather than a new affordance.
|
|
1005
|
+
*
|
|
1006
|
+
* 🎨 `rgb`, `alpha` and `rgb2` joined in issue #730, which makes this the
|
|
1007
|
+
* format's whole slot switch (`SkeletonJson.readAnimation`'s six cases, in its
|
|
1008
|
+
* order). They are the SEPARABLE colour timelines — each poses a subset of the
|
|
1009
|
+
* slot's channels and leaves the rest where they were (`SLOT_COLOR_CHANNELS`)
|
|
1010
|
+
* — and they are not spellings of `rgba`. Folding an `rgb` and an `alpha` with
|
|
1011
|
+
* their own key times into one `rgba` would have to state each channel at the
|
|
1012
|
+
* other's key times, which is a value nobody keyed; and a lone `alpha` has no
|
|
1013
|
+
* `rgba` spelling at all, because an `rgba` key poses the light rgb too. So
|
|
1014
|
+
* each is emitted as itself, and two tracks of one slot that pose a shared
|
|
1015
|
+
* channel are refused rather than layered (see the claim check in `compile`).
|
|
945
1016
|
*
|
|
946
1017
|
* 🎨 `rgba2` joined in issue #690, and what it adds is the half of the two-colour
|
|
947
1018
|
* tint that moves: a slot's `dark` has been an emitted setup field all along
|
|
@@ -952,10 +1023,13 @@ const SLIDER_TRACKS: Record<string, ValueTrackShape> = {
|
|
|
952
1023
|
* never applies it; `--profile spine-html` is where it fires. Emitting a
|
|
953
1024
|
* construct one consumer drops is exactly what a profile is for.
|
|
954
1025
|
*/
|
|
955
|
-
export const SLOT_TRACKS: Record<string, 'attachment' |
|
|
1026
|
+
export const SLOT_TRACKS: Record<string, 'attachment' | SlotColourShape> = {
|
|
956
1027
|
attachment: 'attachment',
|
|
957
1028
|
rgba: 'rgba',
|
|
1029
|
+
rgb: 'rgb',
|
|
1030
|
+
alpha: 'alpha',
|
|
958
1031
|
rgba2: 'rgba2',
|
|
1032
|
+
rgb2: 'rgb2',
|
|
959
1033
|
};
|
|
960
1034
|
|
|
961
1035
|
/**
|
|
@@ -1068,8 +1142,14 @@ const CONSTRAINT_TIMELINES: Record<'ik' | 'transform', ConstraintTimelineShape>
|
|
|
1068
1142
|
},
|
|
1069
1143
|
};
|
|
1070
1144
|
|
|
1071
|
-
/**
|
|
1072
|
-
|
|
1145
|
+
/**
|
|
1146
|
+
* Physics constraint fields and their parser defaults (SkeletonJson.js:295-319).
|
|
1147
|
+
*
|
|
1148
|
+
* `PHYSICS_COMPONENTS` is exported for `ingest.ts`, which omits a constraint that
|
|
1149
|
+
* drives none of them (issue #731) and must read "drives" off the same five
|
|
1150
|
+
* names this module refuses an empty constraint with, not off a copy.
|
|
1151
|
+
*/
|
|
1152
|
+
export const PHYSICS_COMPONENTS = ['x', 'y', 'rotate', 'scaleX', 'shearX'] as const;
|
|
1073
1153
|
const PHYSICS_PARAMS: Array<[string, number]> = [
|
|
1074
1154
|
['inertia', 0.5],
|
|
1075
1155
|
['strength', 100],
|
|
@@ -1404,7 +1484,12 @@ function fromLoosePng(
|
|
|
1404
1484
|
// the rig. The validator's messages are the UI, and so are these.
|
|
1405
1485
|
throw new CompileError(`image "${relPath}" is not on disk at ${absPath}`);
|
|
1406
1486
|
}
|
|
1407
|
-
|
|
1487
|
+
// A loose part IS a page of the atlas this route emits, so it is refused the
|
|
1488
|
+
// way a pack's page is (issue #732): by the one reader's sentence, as a
|
|
1489
|
+
// CompileError, rather than as that reader's throw with a stack under it.
|
|
1490
|
+
const header = readPngHeader(absPath);
|
|
1491
|
+
if (header.problem !== null) throw new CompileError(`image "${relPath}": ${header.problem}`);
|
|
1492
|
+
const info = header.info;
|
|
1408
1493
|
// Page name is the PNG path *relative to the atlas file*, so the viewer
|
|
1409
1494
|
// resolves it the way every Spine consumer does: against the atlas URL.
|
|
1410
1495
|
// The PNGs are not copied: they pass through untouched, and the atlas points
|
|
@@ -1436,6 +1521,25 @@ function readAtlasIn(path: string): AtlasSource {
|
|
|
1436
1521
|
}
|
|
1437
1522
|
}
|
|
1438
1523
|
if (byName.size === 0) throw new CompileError(`--atlas-in ${path} declares no regions`);
|
|
1524
|
+
// 🔒 **What each page IS, where the pack is opened** (issue #732). Every page
|
|
1525
|
+
// the pack names reaches the gate — the atlas this build emits is the pack's
|
|
1526
|
+
// own text — so a page no reader can open is refused here, all of them in
|
|
1527
|
+
// one sentence, rather than one region at a time or at `A06` after the whole
|
|
1528
|
+
// compile. A page that is not on disk is left to the region that sits on it,
|
|
1529
|
+
// which already names it; saying so twice would be two refusals of one fact.
|
|
1530
|
+
const unread: string[] = [];
|
|
1531
|
+
for (const page of parsed.pages) {
|
|
1532
|
+
const abs = resolve(dirname(path), page.name);
|
|
1533
|
+
if (!existsSync(abs)) continue;
|
|
1534
|
+
const { problem } = readPngHeader(abs);
|
|
1535
|
+
if (problem !== null) unread.push(`page "${page.name}": ${problem}`);
|
|
1536
|
+
}
|
|
1537
|
+
if (unread.length > 0) {
|
|
1538
|
+
throw new CompileError(
|
|
1539
|
+
`--atlas-in ${path}: ${unread.length} of its ${parsed.pages.length} page(s) cannot be read as PNG, and ` +
|
|
1540
|
+
`nothing was compiled against the pack — ${unread.join('; ')}`,
|
|
1541
|
+
);
|
|
1542
|
+
}
|
|
1439
1543
|
return { path, dir: dirname(path), parsed, byName };
|
|
1440
1544
|
}
|
|
1441
1545
|
|
|
@@ -2504,6 +2608,30 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
|
|
|
2504
2608
|
);
|
|
2505
2609
|
}
|
|
2506
2610
|
claimed.add(claim);
|
|
2611
|
+
// Two colour timelines of one slot that pose a shared channel are not
|
|
2612
|
+
// two layers of it: each poses its channels at every time, the setup
|
|
2613
|
+
// value before its first key included, so the one the file states
|
|
2614
|
+
// later overwrites the other everywhere (`SLOT_COLOR_CHANNELS`). The
|
|
2615
|
+
// claim above cannot see it — `rgba` and `alpha` are two properties —
|
|
2616
|
+
// and before issue #730 nothing could reach it, because `rgba` and
|
|
2617
|
+
// `rgba2` were the only two and a slot keying both was already a
|
|
2618
|
+
// slot keying its light colour twice. Raised before any key is shaped:
|
|
2619
|
+
// the fault is the pair, not a key.
|
|
2620
|
+
if (family === null && !isBoneTrack) {
|
|
2621
|
+
const mine = SLOT_COLOR_CHANNELS[track.property] ?? [];
|
|
2622
|
+
for (const other of Object.keys(slotTimelines[target] ?? {})) {
|
|
2623
|
+
const shared = (SLOT_COLOR_CHANNELS[other] ?? []).filter((channel) => mine.includes(channel));
|
|
2624
|
+
if (shared.length === 0) continue;
|
|
2625
|
+
throw new CompileError(
|
|
2626
|
+
`animation "${animName}" slot "${target}": tracks "${other}" and "${track.property}" both key the slot's ` +
|
|
2627
|
+
`${shared.map((channel) => CHANNEL_WORDS[channel]).join(' and ')} — a colour timeline poses its ` +
|
|
2628
|
+
'channels at every time, its setup value before its first key included, so the one the file states ' +
|
|
2629
|
+
'later overwrites the other everywhere and the first one\'s keys there are read by nothing. Key each ' +
|
|
2630
|
+
'channel once: "rgb" and "alpha" move the light colour\'s two halves on their own key times, "rgba" ' +
|
|
2631
|
+
'moves them together, and "rgb2" / "rgba2" add the dark colour',
|
|
2632
|
+
);
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2507
2635
|
|
|
2508
2636
|
const shift = (track.lag ?? 0) + (track.stagger ?? 0) * index;
|
|
2509
2637
|
const keys =
|
|
@@ -7252,12 +7380,18 @@ function compileTrack(
|
|
|
7252
7380
|
// …')` inside `RGBA2Timeline.apply1`. That is a crash in the consumer's
|
|
7253
7381
|
// process, from a file every parser accepts — the exact silence this compiler
|
|
7254
7382
|
// exists to convert into a name (issue #690).
|
|
7255
|
-
|
|
7383
|
+
//
|
|
7384
|
+
// `rgb2` is the same pairing and was measured the same way before it joined
|
|
7385
|
+
// this refusal (issue #730): the forged file loads, `setupPose.darkColor` is
|
|
7386
|
+
// `null`, and `state.apply` throws `TypeError: null is not an object
|
|
7387
|
+
// (evaluating 'dark.r = …')` inside `RGB2Timeline.apply1`. The way out it
|
|
7388
|
+
// names is its own light-only sibling, which for `rgb2` is `rgb`.
|
|
7389
|
+
if ((shape === 'rgba2' || shape === 'rgb2') && !darkSlots.has(target)) {
|
|
7256
7390
|
throw new CompileError(
|
|
7257
|
-
`${where}: slot "${target}" declares no setup "dark", and an "
|
|
7391
|
+
`${where}: slot "${target}" declares no setup "dark", and an "${shape}" timeline poses a slot's dark colour — ` +
|
|
7258
7392
|
'the runtime allocates one only for a slot whose setup pose has it, so applying this animation throws ' +
|
|
7259
7393
|
`instead of tinting. Give slot "${target}" a \`dark\` in the rig spec (the colour it holds at rest), or ` +
|
|
7260
|
-
|
|
7394
|
+
`key "${shape === 'rgba2' ? 'rgba' : 'rgb'}" if only the light colour moves`,
|
|
7261
7395
|
);
|
|
7262
7396
|
}
|
|
7263
7397
|
if (!track.keys.length) throw new CompileError(`${where}: no keys`);
|
|
@@ -7298,21 +7432,33 @@ function compileTrack(
|
|
|
7298
7432
|
continue;
|
|
7299
7433
|
}
|
|
7300
7434
|
|
|
7301
|
-
//
|
|
7302
|
-
//
|
|
7303
|
-
//
|
|
7435
|
+
// The five colour shapes `SLOT_TRACKS` names, and between them the only ways
|
|
7436
|
+
// left to reach here: `attachment` returned above and a property the table
|
|
7437
|
+
// does not carry was refused before any key was shaped.
|
|
7304
7438
|
//
|
|
7305
|
-
// ⚠️ The
|
|
7306
|
-
// `
|
|
7307
|
-
// which FIELDS the emitted key spells them as (`color`,
|
|
7308
|
-
// and therefore how long a raw curve array
|
|
7309
|
-
// ease/curve exclusivity, the stepped case, the
|
|
7310
|
-
// refusals — is one rule, and a second copy of it is
|
|
7311
|
-
// in step.
|
|
7312
|
-
const spelling
|
|
7313
|
-
const
|
|
7314
|
-
const colourOf = (v: number[]): Record<string, string> => (shape === 'rgba2' ? rgba2Hex(v) : { color: rgbaHex(v) });
|
|
7439
|
+
// ⚠️ The five differ in three places and nowhere else, so they are read out
|
|
7440
|
+
// of `COLOUR_KEYS` rather than forked into five loops: how many channels a
|
|
7441
|
+
// `v` carries, which FIELDS the emitted key spells them as (`color`,
|
|
7442
|
+
// `value`, or `light` + `dark`), and therefore how long a raw curve array
|
|
7443
|
+
// is. Everything else — the ease/curve exclusivity, the stepped case, the
|
|
7444
|
+
// hold test, the last-key refusals — is one rule, and a second copy of it is
|
|
7445
|
+
// a second thing to keep in step.
|
|
7446
|
+
const { spelling, channels } = COLOUR_KEYS[shape];
|
|
7447
|
+
const colourOf = (v: number[]): Record<string, string | number> => colourKey(shape, v);
|
|
7315
7448
|
if (!Array.isArray(key.v)) throw new CompileError(`${where}: ${shape} key value must be ${spelling}`);
|
|
7449
|
+
if (shape === 'alpha' && key.v.length === 1 && !(Number.isFinite(key.v[0]) && key.v[0] >= 0 && key.v[0] <= 1)) {
|
|
7450
|
+
// The one colour shape written as a number rather than a byte, so the one
|
|
7451
|
+
// that is not clamped onto 0..1 by `channelHex` on the way out. The
|
|
7452
|
+
// runtime clamps the POSED alpha (`AlphaTimeline.apply`: `color.a = a < 0
|
|
7453
|
+
// ? 0 : a > 1 ? 1 : a`) but interpolates the stored value first, so a key
|
|
7454
|
+
// of 1.5 reaches 1 at a different time from a key of 1 — a curve nobody
|
|
7455
|
+
// keyed, from a value no pose ever holds. Refused rather than clamped:
|
|
7456
|
+
// clamping is choosing the value.
|
|
7457
|
+
throw new CompileError(
|
|
7458
|
+
`${where}: key at t=${key.t} is ${String(key.v[0])}; an alpha is a number from 0 to 1 — the runtime clamps ` +
|
|
7459
|
+
'the posed alpha to that range after interpolating, so a value outside it is one no pose ever holds',
|
|
7460
|
+
);
|
|
7461
|
+
}
|
|
7316
7462
|
const entry: SpineTimelineKey = { time, ...colourOf(key.v) };
|
|
7317
7463
|
if (key.ease !== undefined && key.curve !== undefined) {
|
|
7318
7464
|
throw new CompileError(`${where}: a key carries both a named easing and a raw curve; pick one`);
|
|
@@ -7334,8 +7480,9 @@ function compileTrack(
|
|
|
7334
7480
|
}
|
|
7335
7481
|
const t2 = keyTime(next.t + shift);
|
|
7336
7482
|
// 4 numbers per channel, in the format's own channel order — 16 for
|
|
7337
|
-
// `rgba` (r g b a),
|
|
7338
|
-
// is the order `readCurve` indexes them in)
|
|
7483
|
+
// `rgba` (r g b a), 12 for `rgb`, 4 for `alpha`, 28 for `rgba2` (light r
|
|
7484
|
+
// g b a, then dark r g b, which is the order `readCurve` indexes them in)
|
|
7485
|
+
// and 24 for `rgb2`. Short arrays become NaN
|
|
7339
7486
|
// curves with no error. The hold test reads the emitted hex rather than
|
|
7340
7487
|
// the authored floats: two colours that quantise to one byte are one
|
|
7341
7488
|
// colour in the file, and for `rgba2` that has to be true of BOTH — a key
|
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 -------------------------------------------------------------
|
|
@@ -1247,7 +1353,14 @@ function ingestAttachment(
|
|
|
1247
1353
|
}
|
|
1248
1354
|
|
|
1249
1355
|
/** One animation. Inverts step 5 of `compile()` — the whole timeline half. */
|
|
1250
|
-
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 {
|
|
1251
1364
|
const tracks: JsonObject[] = [];
|
|
1252
1365
|
let maxT = 0;
|
|
1253
1366
|
const seeT = (t: number): void => {
|
|
@@ -1338,6 +1451,20 @@ function ingestAnimation(animName: string, anim: JsonObject, root: JsonObject, n
|
|
|
1338
1451
|
const family = (group: 'path' | 'physics' | 'slider', shapes: Record<string, TrackShape>): void => {
|
|
1339
1452
|
for (const [name, timelines] of objEntries(anim[group])) {
|
|
1340
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
|
+
}
|
|
1341
1468
|
const shape = shapes[property];
|
|
1342
1469
|
const where = `animation "${animName}" ${group} "${name}" ${property}`;
|
|
1343
1470
|
if (shape === undefined) {
|
|
@@ -1413,14 +1540,58 @@ function ingestAnimation(animName: string, anim: JsonObject, root: JsonObject, n
|
|
|
1413
1540
|
return entry;
|
|
1414
1541
|
}),
|
|
1415
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
|
+
});
|
|
1416
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;
|
|
1417
1583
|
note(
|
|
1418
1584
|
'blocker',
|
|
1419
1585
|
'SLOT_TIMELINE',
|
|
1420
1586
|
where,
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
`
|
|
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
|
+
: ''),
|
|
1424
1595
|
);
|
|
1425
1596
|
}
|
|
1426
1597
|
}
|