spine-rigc 0.30.0 → 0.31.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/cli.ts +39 -1
- package/docs/AUTHORING.md +104 -21
- package/docs/INGEST.md +4 -3
- package/package.json +1 -1
- package/src/atlas.ts +180 -0
- package/src/compile.ts +158 -11
- package/src/ingest.ts +100 -4
- package/src/motion.ts +38 -1
- package/src/rig.ts +34 -2
- package/src/types.ts +43 -3
- package/src/validate.ts +187 -158
package/src/compile.ts
CHANGED
|
@@ -39,8 +39,9 @@ import { parseJsonWithPosition } from './json-position.ts';
|
|
|
39
39
|
// of it — the same search serves `refuseUnknownKeys`, and a second copy here with
|
|
40
40
|
// a threshold edited is how such a pair drifts apart.
|
|
41
41
|
import { nearMisses } from './keys.ts';
|
|
42
|
-
import { parseMotionSpec } from './motion.ts';
|
|
42
|
+
import { EVERY_GLOBAL_PHYSICS, parseMotionSpec } from './motion.ts';
|
|
43
43
|
import {
|
|
44
|
+
BONE_INHERIT_KNOWN,
|
|
44
45
|
constraintAt,
|
|
45
46
|
declaresNoStage,
|
|
46
47
|
parseRigSpec,
|
|
@@ -50,6 +51,7 @@ import {
|
|
|
50
51
|
RIG_PATH_SPACING_MODES,
|
|
51
52
|
RIG_SCALE_Y_MODES,
|
|
52
53
|
RIG_SKIN_CONSTRAINT_KEYS,
|
|
54
|
+
resolveBoneInherit,
|
|
53
55
|
splitRigSkin,
|
|
54
56
|
type RigAttachment,
|
|
55
57
|
type RigBone,
|
|
@@ -89,6 +91,8 @@ import { Plate, readPlate } from '../tools/plate.ts';
|
|
|
89
91
|
import {
|
|
90
92
|
extractRegion,
|
|
91
93
|
pageFootprint,
|
|
94
|
+
pageGridSaid,
|
|
95
|
+
pageGridSentence,
|
|
92
96
|
parseAtlasText,
|
|
93
97
|
rewritePageNames,
|
|
94
98
|
writeAtlasText,
|
|
@@ -864,6 +868,17 @@ interface ValueTrackShape {
|
|
|
864
868
|
fields: string[];
|
|
865
869
|
identity: number[];
|
|
866
870
|
bounds?: Array<PhysicsPoseRule | null>;
|
|
871
|
+
/**
|
|
872
|
+
* A timeline whose key holds one NAMED value instead of numbers — the JSON
|
|
873
|
+
* field it is written as, and the resolver that turns a spelling into the
|
|
874
|
+
* name the file carries (or `undefined` for one the runtime cannot resolve).
|
|
875
|
+
*
|
|
876
|
+
* ⚠️ Such a timeline is stepped by the format rather than by choice: its
|
|
877
|
+
* reader builds no curve at all, so an `ease` or a `curve` on its key would be
|
|
878
|
+
* accepted by the parser and never read. `compileValueTrack` refuses both.
|
|
879
|
+
* One entry has it — the bone's `inherit` (issue #733).
|
|
880
|
+
*/
|
|
881
|
+
named?: { field: string; resolve: (value: unknown) => string | undefined; known: string };
|
|
867
882
|
}
|
|
868
883
|
|
|
869
884
|
/**
|
|
@@ -898,6 +913,12 @@ const BONE_TRACKS: Record<string, ValueTrackShape> = {
|
|
|
898
913
|
shearx: { fields: ['value'], identity: [0] },
|
|
899
914
|
sheary: { fields: ['value'], identity: [0] },
|
|
900
915
|
rotate: { fields: ['value'], identity: [0] },
|
|
916
|
+
// The eleventh case of the runtime's bone switch, and the one that keys no
|
|
917
|
+
// number: `InheritTimeline` sets `pose.inherit` at the key and
|
|
918
|
+
// `updateWorldTransform` reads it that frame. Resolved by the SAME call the
|
|
919
|
+
// setup field is (`resolveBoneInherit`), so the five a key may name are the
|
|
920
|
+
// five a bone may rest in and a refusal prints the one list (issue #733).
|
|
921
|
+
inherit: { fields: [], identity: [], named: { field: 'inherit', resolve: resolveBoneInherit, known: BONE_INHERIT_KNOWN } },
|
|
901
922
|
};
|
|
902
923
|
|
|
903
924
|
/**
|
|
@@ -1505,6 +1526,11 @@ interface AtlasSource {
|
|
|
1505
1526
|
parsed: ParsedAtlas;
|
|
1506
1527
|
/** Trimmed region name -> the region, first occurrence wins (as `findRegion` does). */
|
|
1507
1528
|
byName: Map<string, { region: AtlasRegion; page: ParsedAtlas['pages'][number] }>;
|
|
1529
|
+
/**
|
|
1530
|
+
* Page name -> what its file is against what the atlas declares, for every
|
|
1531
|
+
* page on disk whose PNG is not the declared size, in page order (issue #750).
|
|
1532
|
+
*/
|
|
1533
|
+
grids: Map<string, NonNullable<CompiledImage['pageGrid']>>;
|
|
1508
1534
|
}
|
|
1509
1535
|
|
|
1510
1536
|
function readAtlasIn(path: string): AtlasSource {
|
|
@@ -1527,12 +1553,28 @@ function readAtlasIn(path: string): AtlasSource {
|
|
|
1527
1553
|
// one sentence, rather than one region at a time or at `A06` after the whole
|
|
1528
1554
|
// compile. A page that is not on disk is left to the region that sits on it,
|
|
1529
1555
|
// which already names it; saying so twice would be two refusals of one fact.
|
|
1556
|
+
//
|
|
1557
|
+
// 📐 **What each page MEASURES, read in the same pass and not refused here**
|
|
1558
|
+
// (issue #750). A page whose PNG is not the size the atlas declares is a
|
|
1559
|
+
// file every runtime still draws — the region mapping is a fraction of the
|
|
1560
|
+
// declared size — and the only readers it breaks are the ones that address
|
|
1561
|
+
// it in texels. Refusing it here would refuse every pack that has one,
|
|
1562
|
+
// including a pack of region attachments nothing reads a texel of, and on
|
|
1563
|
+
// `build` it would replace `A06`'s verdict with a compile error for the same
|
|
1564
|
+
// fact: `A06` already refuses the pack, names the ratio and prints the
|
|
1565
|
+
// `scale:` repair, before anything is written. So the page is RECORDED, as
|
|
1566
|
+
// `pageGridSentence` states it, and the readers that would take a figure off
|
|
1567
|
+
// its texels decline to on the part that asks (`partTexels` below).
|
|
1530
1568
|
const unread: string[] = [];
|
|
1569
|
+
const grids: AtlasSource['grids'] = new Map();
|
|
1531
1570
|
for (const page of parsed.pages) {
|
|
1532
1571
|
const abs = resolve(dirname(path), page.name);
|
|
1533
1572
|
if (!existsSync(abs)) continue;
|
|
1534
|
-
const { problem } = readPngHeader(abs);
|
|
1573
|
+
const { info, problem } = readPngHeader(abs);
|
|
1535
1574
|
if (problem !== null) unread.push(`page "${page.name}": ${problem}`);
|
|
1575
|
+
if (info === null) continue;
|
|
1576
|
+
const sentence = pageGridSentence(page, info, page.regions);
|
|
1577
|
+
if (sentence !== null) grids.set(page.name, { said: pageGridSaid(page, info), sentence });
|
|
1536
1578
|
}
|
|
1537
1579
|
if (unread.length > 0) {
|
|
1538
1580
|
throw new CompileError(
|
|
@@ -1540,7 +1582,7 @@ function readAtlasIn(path: string): AtlasSource {
|
|
|
1540
1582
|
`nothing was compiled against the pack — ${unread.join('; ')}`,
|
|
1541
1583
|
);
|
|
1542
1584
|
}
|
|
1543
|
-
return { path, dir: dirname(path), parsed, byName };
|
|
1585
|
+
return { path, dir: dirname(path), parsed, byName, grids };
|
|
1544
1586
|
}
|
|
1545
1587
|
|
|
1546
1588
|
/**
|
|
@@ -1634,6 +1676,7 @@ function resolveFromAtlas(
|
|
|
1634
1676
|
const info = readPngInfo(absPath);
|
|
1635
1677
|
const page = relative(outDir, absPath).split('\\').join('/');
|
|
1636
1678
|
const scale = found.page.scale;
|
|
1679
|
+
const grid = atlas.grids.get(found.page.name);
|
|
1637
1680
|
return {
|
|
1638
1681
|
region,
|
|
1639
1682
|
page,
|
|
@@ -1647,6 +1690,7 @@ function resolveFromAtlas(
|
|
|
1647
1690
|
isBase,
|
|
1648
1691
|
atlas: found.region,
|
|
1649
1692
|
...(scale === 1 ? {} : { atlasScale: scale }),
|
|
1693
|
+
...(grid === undefined ? {} : { pageGrid: grid }),
|
|
1650
1694
|
};
|
|
1651
1695
|
}
|
|
1652
1696
|
|
|
@@ -2395,6 +2439,16 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
|
|
|
2395
2439
|
/** kind -> the names it declares, for the "the rig declares: …" half of one. */
|
|
2396
2440
|
const constraintNamesOfKind = new Map<string, string[]>();
|
|
2397
2441
|
const declareConstraint = (name: string, type: string): void => {
|
|
2442
|
+
// Reserved, because a track's `"physics": "*"` is the timeline that names no
|
|
2443
|
+
// constraint (issue #726): a physics constraint of that name is one no track
|
|
2444
|
+
// could key by name without meaning every global one instead.
|
|
2445
|
+
if (type === 'physics' && name === EVERY_GLOBAL_PHYSICS) {
|
|
2446
|
+
throw new CompileError(
|
|
2447
|
+
`physics constraint "${EVERY_GLOBAL_PHYSICS}": the name is reserved — a motion spec's \`"physics": ` +
|
|
2448
|
+
`"${EVERY_GLOBAL_PHYSICS}"\` is the timeline that names no constraint and drives every physics constraint ` +
|
|
2449
|
+
'declaring the keyed property global, so a constraint called that could not be keyed by name. Rename it',
|
|
2450
|
+
);
|
|
2451
|
+
}
|
|
2398
2452
|
constraintDeclared.add(constraintAt(type, name));
|
|
2399
2453
|
constraintKinds.set(name, [...(constraintKinds.get(name) ?? []), type]);
|
|
2400
2454
|
constraintNamesOfKind.set(type, [...(constraintNamesOfKind.get(type) ?? []), name]);
|
|
@@ -2568,7 +2622,40 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
|
|
|
2568
2622
|
const perMember = resolveMemberTrack(track, animName, targets, bones, trackDerivations);
|
|
2569
2623
|
targets.forEach((target, index) => {
|
|
2570
2624
|
const resolved = perMember.get(target)!;
|
|
2571
|
-
|
|
2625
|
+
// The timeline that names no constraint (issue #726). Resolved against
|
|
2626
|
+
// the constraints' own `…Global` flags rather than a name, and refused
|
|
2627
|
+
// when it reaches none — the runtime would walk every physics
|
|
2628
|
+
// constraint, skip each one, and the file would parse and do nothing.
|
|
2629
|
+
const everyGlobal = family === 'physics' && target === EVERY_GLOBAL_PHYSICS;
|
|
2630
|
+
if (everyGlobal && track.physics === undefined) {
|
|
2631
|
+
throw new CompileError(
|
|
2632
|
+
`animation "${animName}": group "${String(track.group)}" lists "${EVERY_GLOBAL_PHYSICS}", which is not a ` +
|
|
2633
|
+
`constraint but the target that names none — write it as the track's \`"physics": "${EVERY_GLOBAL_PHYSICS}"\``,
|
|
2634
|
+
);
|
|
2635
|
+
}
|
|
2636
|
+
if (everyGlobal) {
|
|
2637
|
+
const physics = constraints.filter((one) => one.type === 'physics');
|
|
2638
|
+
const flag = `${track.property}Global`;
|
|
2639
|
+
const reached =
|
|
2640
|
+
track.property === 'reset'
|
|
2641
|
+
? physics
|
|
2642
|
+
: physics.filter((one) => one[flag] === true);
|
|
2643
|
+
if (reached.length === 0) {
|
|
2644
|
+
throw new CompileError(
|
|
2645
|
+
`animation "${animName}" keys physics "${EVERY_GLOBAL_PHYSICS}" ${track.property}, the timeline that ` +
|
|
2646
|
+
`names no constraint and drives every physics constraint ` +
|
|
2647
|
+
(track.property === 'reset' ? 'the rig has' : `declaring "${flag}": true`) +
|
|
2648
|
+
', and ' +
|
|
2649
|
+
(physics.length === 0
|
|
2650
|
+
? 'the rig declares no physics constraint'
|
|
2651
|
+
: `none of ${physics.map((one) => `"${one.name}"`).join(', ')} does`) +
|
|
2652
|
+
' — it would parse and move nothing. ' +
|
|
2653
|
+
(track.property === 'reset'
|
|
2654
|
+
? 'Declare a physics constraint, or remove the track'
|
|
2655
|
+
: `Set "${flag}": true on the constraints it is for, or key one by name`),
|
|
2656
|
+
);
|
|
2657
|
+
}
|
|
2658
|
+
} else if (family !== null) {
|
|
2572
2659
|
const label = CONSTRAINT_TRACK_FAMILIES[family].label;
|
|
2573
2660
|
// Resolved by name AND by type in the parser
|
|
2574
2661
|
// (`findConstraint(name, PathConstraintData)`), which returns null on
|
|
@@ -2650,7 +2737,8 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
|
|
|
2650
2737
|
? compileValueTrack(resolved, motion, animName, anim.duration, target, shift, BONE_TRACKS, 'bone')
|
|
2651
2738
|
: compileTrack(resolved, motion, animName, anim.duration, target, shift, attachmentIndex, darkSlots);
|
|
2652
2739
|
for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
|
|
2653
|
-
|
|
2740
|
+
// Under the empty name, which is the file's own spelling of it.
|
|
2741
|
+
if (family !== null) (familyTimelines[family][everyGlobal ? '' : target] ??= {})[track.property] = keys;
|
|
2654
2742
|
else if (isBoneTrack) (boneTimelines[target] ??= {})[track.property] = keys;
|
|
2655
2743
|
else (slotTimelines[target] ??= {})[track.property] = keys;
|
|
2656
2744
|
});
|
|
@@ -2887,6 +2975,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
|
|
|
2887
2975
|
skeletonText: `${JSON.stringify(skeleton, null, 2)}\n`,
|
|
2888
2976
|
atlasText,
|
|
2889
2977
|
images,
|
|
2978
|
+
pageGrids: atlasIn === null ? [] : [...atlasIn.grids].map(([page, grid]) => ({ page, said: grid.said })),
|
|
2890
2979
|
droppedStates,
|
|
2891
2980
|
absentParts,
|
|
2892
2981
|
declaredDurations,
|
|
@@ -3986,11 +4075,20 @@ function encodeNamedWeights(weights: RigMeshBinding[][], where: string, ctx: Att
|
|
|
3986
4075
|
* what `extractRegion` does, so there is no unmeasurable case left for this
|
|
3987
4076
|
* function to report and no catch here to keep reachable.
|
|
3988
4077
|
*/
|
|
3989
|
-
function measureAuthoredFit(
|
|
4078
|
+
function measureAuthoredFit(
|
|
4079
|
+
att: RigMeshAttachment,
|
|
4080
|
+
ctx: AttachmentContext,
|
|
4081
|
+
): MeshFitReport | { withheld: string } | null {
|
|
3990
4082
|
if (att.image === undefined || att.uvs === undefined || att.triangles === undefined) return null;
|
|
3991
4083
|
const region = basename(att.image, '.png');
|
|
3992
4084
|
const img = ctx.images.find((im) => im.region === region);
|
|
3993
4085
|
if (!img) return null;
|
|
4086
|
+
// A third case, and neither of the two above (issue #750): there IS art to
|
|
4087
|
+
// compare against, and its texels cannot be located, because its page's file
|
|
4088
|
+
// is not the size the atlas declares. Withheld and said, rather than measured
|
|
4089
|
+
// over whatever the declared coordinates land on — on a half-resolution pack
|
|
4090
|
+
// that read 68.49% where the page it was packed from reads 100.00%.
|
|
4091
|
+
if (img.pageGrid !== undefined) return { withheld: img.pageGrid.said };
|
|
3994
4092
|
const plate = partPlate(img);
|
|
3995
4093
|
const alpha = new Uint8Array(plate.width * plate.height);
|
|
3996
4094
|
for (let i = 0; i < alpha.length; i++) alpha[i] = plate.data[i * 4 + 3];
|
|
@@ -4159,7 +4257,8 @@ function buildRigMesh(
|
|
|
4159
4257
|
// read this and skip rather than measuring a ring that was never a ring.
|
|
4160
4258
|
ctx.meshBones.add(ctx.anchorBone);
|
|
4161
4259
|
for (const name of boundBones) ctx.meshBones.add(name);
|
|
4162
|
-
const
|
|
4260
|
+
const measured = measureAuthoredFit(att, ctx);
|
|
4261
|
+
const fit = measured === null || 'withheld' in measured ? null : measured;
|
|
4163
4262
|
ctx.meshes.push({
|
|
4164
4263
|
slot: ctx.slotName,
|
|
4165
4264
|
kind: 'authored',
|
|
@@ -4169,6 +4268,7 @@ function buildRigMesh(
|
|
|
4169
4268
|
bones: boundBones.length ? boundBones : [ctx.anchorBone],
|
|
4170
4269
|
coverage: fit === null ? undefined : r6(fit.coverage),
|
|
4171
4270
|
overshoot: fit?.overshoot,
|
|
4271
|
+
...(measured !== null && 'withheld' in measured ? { fitWithheld: measured.withheld } : {}),
|
|
4172
4272
|
});
|
|
4173
4273
|
return out;
|
|
4174
4274
|
}
|
|
@@ -4526,8 +4626,13 @@ function sampleMeshDepth(
|
|
|
4526
4626
|
*
|
|
4527
4627
|
* The two are different questions and the header says why. This one has no
|
|
4528
4628
|
* refusal behind it: it is counted, reported, and left to the author.
|
|
4629
|
+
*
|
|
4630
|
+
* `unlocated` in its place when the part's texels cannot be located — a
|
|
4631
|
+
* region lifted off a page whose file is not its declared size (issue #750),
|
|
4632
|
+
* carrying `pageGridSaid`'s clause — and the count is then `null` with that
|
|
4633
|
+
* clause beside it: withheld, rather than taken off another part of the page.
|
|
4529
4634
|
*/
|
|
4530
|
-
partAlpha: Uint8Array,
|
|
4635
|
+
partAlpha: Uint8Array | { unlocated: string },
|
|
4531
4636
|
partWidth: number,
|
|
4532
4637
|
partHeight: number,
|
|
4533
4638
|
where: string,
|
|
@@ -4587,7 +4692,7 @@ function sampleMeshDepth(
|
|
|
4587
4692
|
// Zero, not a threshold: "the part image draws nothing here" is a fact
|
|
4588
4693
|
// about the file, and any other cut-off would be rigc deciding how faint
|
|
4589
4694
|
// a texel has to be before it stops counting as art.
|
|
4590
|
-
if (partAlpha[at] === 0) drawn = false;
|
|
4695
|
+
if (partAlpha instanceof Uint8Array && partAlpha[at] === 0) drawn = false;
|
|
4591
4696
|
}
|
|
4592
4697
|
if (!opaqueEverywhere && !covered) uncovered.push(v);
|
|
4593
4698
|
if (!drawn) undrawn++;
|
|
@@ -4632,7 +4737,7 @@ function sampleMeshDepth(
|
|
|
4632
4737
|
zScale: spec.zScale,
|
|
4633
4738
|
tone,
|
|
4634
4739
|
range: [r6(lo), r6(hi)],
|
|
4635
|
-
undrawn,
|
|
4740
|
+
...(partAlpha instanceof Uint8Array ? { undrawn } : { undrawn: null, unlocated: partAlpha.unlocated }),
|
|
4636
4741
|
ceiling: turnCeiling(
|
|
4637
4742
|
points.map(([px, py]) => toBind(px, py)),
|
|
4638
4743
|
z,
|
|
@@ -4870,7 +4975,11 @@ function buildGridAttachment(
|
|
|
4870
4975
|
geometry.points,
|
|
4871
4976
|
geometry.triangles,
|
|
4872
4977
|
(px, py) => toBoneLocal(anchor, anchor.worldX + px * toArt - w / 2, anchor.worldY + h / 2 - py * toArt),
|
|
4873
|
-
|
|
4978
|
+
// The lattice's geometry takes nothing off the texels — its window is
|
|
4979
|
+
// the plate's size, which is the atlas's own `offsets` — so a page
|
|
4980
|
+
// whose file is not its declared size costs a grid only this one
|
|
4981
|
+
// count, and the count is what is withheld (issue #750).
|
|
4982
|
+
img.pageGrid === undefined ? plateAlpha(plate) : { unlocated: img.pageGrid.said },
|
|
4874
4983
|
plate.width,
|
|
4875
4984
|
plate.height,
|
|
4876
4985
|
where,
|
|
@@ -4959,6 +5068,24 @@ function buildContourAttachment(
|
|
|
4959
5068
|
);
|
|
4960
5069
|
}
|
|
4961
5070
|
const img = atlasedImage(att.image, where, ctx);
|
|
5071
|
+
// 🔒 **A trace needs the part's texels, and on a page that is not its
|
|
5072
|
+
// declared size the lift does not have them** (issue #750). `extractRegion`
|
|
5073
|
+
// addresses the page at the coordinates the atlas states; on a file of any
|
|
5074
|
+
// other size those are a different part of the picture — measured on a
|
|
5075
|
+
// half-resolution pack, the lift came back with no pixel above alpha 0 and
|
|
5076
|
+
// the build refused with "there is no silhouette to trace", a true sentence
|
|
5077
|
+
// about the wrong texels. Unlike an authored mesh's fit, which is a
|
|
5078
|
+
// measurement and is withheld, this outline IS the geometry: there is no mesh
|
|
5079
|
+
// to build without it, and the compiler invents none. So it is refused, with
|
|
5080
|
+
// `A06`'s own sentence, which names the ratio and the repair — on `build`
|
|
5081
|
+
// this refusal arrives before the gate, where `A06` would have said it.
|
|
5082
|
+
if (img.pageGrid !== undefined) {
|
|
5083
|
+
throw new CompileError(
|
|
5084
|
+
`${where}: a "contour" generator traces the part's own alpha, and "${att.image}" is lifted off a packed ` +
|
|
5085
|
+
'page at the coordinates the atlas states, which on this file are not where its texels are — so there is ' +
|
|
5086
|
+
`no silhouette here to trace, only another part of the page. ${img.pageGrid.sentence}`,
|
|
5087
|
+
);
|
|
5088
|
+
}
|
|
4962
5089
|
// ⚠️ Nothing here reads the PNG's colour type. `hasAlpha` answers "where does
|
|
4963
5090
|
// this file keep its alpha", not "is any pixel of it transparent" — a tRNS
|
|
4964
5091
|
// chunk is real transparency (issue #215) and an all-255 alpha channel is
|
|
@@ -5968,6 +6095,26 @@ function compileValueTrack(
|
|
|
5968
6095
|
throw new CompileError(`${where}: key times must strictly increase (at t=${key.t})`);
|
|
5969
6096
|
}
|
|
5970
6097
|
checkKeyTime(where, time, key.t, duration);
|
|
6098
|
+
// A named-value timeline (`inherit`): the key's `v` is one name, stepped by
|
|
6099
|
+
// the format. The name is resolved rather than copied, and what is WRITTEN
|
|
6100
|
+
// is the resolver's spelling — so `NoScale`, which the runtime also reads,
|
|
6101
|
+
// emits as the `noScale` an editor writes, and a spelling the runtime
|
|
6102
|
+
// cannot resolve never reaches the file at all.
|
|
6103
|
+
if (shape.named !== undefined) {
|
|
6104
|
+
if (key.ease !== undefined || key.curve !== undefined) {
|
|
6105
|
+
throw new CompileError(
|
|
6106
|
+
`${where} key at t=${key.t} carries ${key.ease !== undefined ? `an easing ("${key.ease}")` : 'a curve'}, and ` +
|
|
6107
|
+
`this timeline is stepped by the format — its reader builds no curve, so the mode changes AT the key and ` +
|
|
6108
|
+
'holds until the next one. Remove it',
|
|
6109
|
+
);
|
|
6110
|
+
}
|
|
6111
|
+
const mode = shape.named.resolve(key.v);
|
|
6112
|
+
if (mode === undefined) {
|
|
6113
|
+
throw new CompileError(`${where} key at t=${key.t} names mode ${JSON.stringify(key.v)}; ${shape.named.known}`);
|
|
6114
|
+
}
|
|
6115
|
+
out.push({ time, [shape.named.field]: mode });
|
|
6116
|
+
continue;
|
|
6117
|
+
}
|
|
5971
6118
|
// A no-field timeline (`reset`) is an event: the key IS the value, so it
|
|
5972
6119
|
// carries none. Anything else must match the field count exactly.
|
|
5973
6120
|
if (shape.fields.length === 0) {
|
package/src/ingest.ts
CHANGED
|
@@ -54,8 +54,8 @@ import {
|
|
|
54
54
|
spineGeneration,
|
|
55
55
|
TOPLEVEL_CONSTRAINT_ARRAYS,
|
|
56
56
|
} 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';
|
|
57
|
+
import { EVERY_GLOBAL_PHYSICS, MOTION_SPEC_VERSION, parseMotionSpec } from './motion.ts';
|
|
58
|
+
import { parseRigSpec, resolveBoneInherit, RIG_KEYS, RIG_SPEC_VERSION, type RigSpec } from './rig.ts';
|
|
59
59
|
import type { MotionSpec } from './types.ts';
|
|
60
60
|
|
|
61
61
|
/**
|
|
@@ -374,6 +374,18 @@ const BONE_TRACKS: Record<string, TrackShape> = {
|
|
|
374
374
|
rotate: [['value', 0]],
|
|
375
375
|
};
|
|
376
376
|
|
|
377
|
+
/**
|
|
378
|
+
* The bone timeline whose key holds a NAME rather than numbers — `inherit`,
|
|
379
|
+
* `{ time, inherit }`, stepped by the format (its reader builds no curve). The
|
|
380
|
+
* field and the default `SkeletonJson`'s `inherit` branch reads where a key
|
|
381
|
+
* omits it: `getValue(aFrame, "inherit", "Normal")`, which the table spells
|
|
382
|
+
* `normal`. Inverts `compileValueTrack`'s named branch (issue #733).
|
|
383
|
+
*/
|
|
384
|
+
const INHERIT_TRACK = { property: 'inherit', field: 'inherit', dflt: 'normal' } as const;
|
|
385
|
+
|
|
386
|
+
/** Every bone timeline the motion spec has a track for, in the order the refusal prints them. */
|
|
387
|
+
const BONE_TRACK_NAMES = [...Object.keys(BONE_TRACKS), INHERIT_TRACK.property];
|
|
388
|
+
|
|
377
389
|
/**
|
|
378
390
|
* The eight physics timelines — `PHYSICS_TRACKS` in `compile.ts`, same order.
|
|
379
391
|
* `reset` carries no value at all — `compileValueTrack`'s zero-field branch.
|
|
@@ -516,7 +528,7 @@ export const UNSPELT_SLOT_TRACKS = Object.keys(CHANNELS_BY_KIND.slot).filter((na
|
|
|
516
528
|
export const INGEST_VOCABULARY = {
|
|
517
529
|
attachments: ATTACHMENT_TYPES,
|
|
518
530
|
constraints: Object.keys(CONSTRAINT_FIELDS),
|
|
519
|
-
boneTracks:
|
|
531
|
+
boneTracks: BONE_TRACK_NAMES,
|
|
520
532
|
slotTracks: SLOT_TRACKS,
|
|
521
533
|
path: Object.keys(PATH_TRACKS),
|
|
522
534
|
physics: Object.keys(PHYSICS_TRACKS),
|
|
@@ -1448,6 +1460,16 @@ function ingestAnimation(
|
|
|
1448
1460
|
* `lossy`, on the argument that the rebuilt skeleton plays identically — is
|
|
1449
1461
|
* a decision about all three families and is not made here.
|
|
1450
1462
|
*/
|
|
1463
|
+
/**
|
|
1464
|
+
* The physics constraints the rebuild carries — every one the file declares
|
|
1465
|
+
* but those `ingest` omits as inert — which is what an unnamed physics
|
|
1466
|
+
* timeline can reach in it.
|
|
1467
|
+
*/
|
|
1468
|
+
const carriedPhysics = arr(root.constraints)
|
|
1469
|
+
.map(obj)
|
|
1470
|
+
.filter((one) => one.type === 'physics' && !(typeof one.name === 'string' && inert.has(one.name)));
|
|
1471
|
+
/** Unnamed physics timelines that reach nothing in the rebuild, said once the duration is known. */
|
|
1472
|
+
const unreached: Array<{ property: string; lastKey: number }> = [];
|
|
1451
1473
|
const family = (group: 'path' | 'physics' | 'slider', shapes: Record<string, TrackShape>): void => {
|
|
1452
1474
|
for (const [name, timelines] of objEntries(anim[group])) {
|
|
1453
1475
|
for (const [property, keys] of arrEntries(timelines)) {
|
|
@@ -1471,6 +1493,26 @@ function ingestAnimation(
|
|
|
1471
1493
|
note('blocker', `${group.toUpperCase()}_TIMELINE`, where, `timeline "${property}" is not in the motion spec`);
|
|
1472
1494
|
continue;
|
|
1473
1495
|
}
|
|
1496
|
+
// The empty name is the physics group's timeline that names no
|
|
1497
|
+
// constraint (issue #726), which the motion spec spells `"*"`. It
|
|
1498
|
+
// writes every carried constraint declaring the property global —
|
|
1499
|
+
// `reset` every one — and one that reaches none is a no-op the rebuild
|
|
1500
|
+
// would be refused over by name, so it goes, and is said, instead.
|
|
1501
|
+
if (group === 'physics' && name === '') {
|
|
1502
|
+
const reaches =
|
|
1503
|
+
property === 'reset' ? carriedPhysics.length > 0 : carriedPhysics.some((one) => Boolean(one[`${property}Global`]));
|
|
1504
|
+
if (!reaches) {
|
|
1505
|
+
let lastKey = 0;
|
|
1506
|
+
for (const raw of keys) {
|
|
1507
|
+
const t = obj(raw).time;
|
|
1508
|
+
if (typeof t === 'number' && t > lastKey) lastKey = t;
|
|
1509
|
+
}
|
|
1510
|
+
unreached.push({ property, lastKey });
|
|
1511
|
+
continue;
|
|
1512
|
+
}
|
|
1513
|
+
valueTrack({ physics: EVERY_GLOBAL_PHYSICS }, property, keys, shape, where);
|
|
1514
|
+
continue;
|
|
1515
|
+
}
|
|
1474
1516
|
valueTrack({ [group]: name }, property, keys, shape, where);
|
|
1475
1517
|
}
|
|
1476
1518
|
}
|
|
@@ -1480,12 +1522,46 @@ function ingestAnimation(
|
|
|
1480
1522
|
for (const [property, keys] of arrEntries(timelines)) {
|
|
1481
1523
|
const shape = BONE_TRACKS[property];
|
|
1482
1524
|
const where = `animation "${animName}" bone "${bone}" ${property}`;
|
|
1525
|
+
if (property === INHERIT_TRACK.property) {
|
|
1526
|
+
// `{ time, inherit }` -> `{ t, v: mode }`. The mode is carried in the
|
|
1527
|
+
// table's spelling, which is what `build` writes back; a key that omits
|
|
1528
|
+
// it, or spells it with a capital the runtime also folds, is a key the
|
|
1529
|
+
// rebuild states differently and is counted as such. A spelling the
|
|
1530
|
+
// runtime cannot resolve at all is carried as written — the file plays
|
|
1531
|
+
// no mode there, and `build` refuses it by name rather than guessing one.
|
|
1532
|
+
let restated = 0;
|
|
1533
|
+
const out = keys.map((raw) => {
|
|
1534
|
+
const key = obj(raw);
|
|
1535
|
+
const entry: JsonObject = { t: timeOf(key) };
|
|
1536
|
+
const written = key[INHERIT_TRACK.field];
|
|
1537
|
+
const mode = written === undefined ? INHERIT_TRACK.dflt : resolveBoneInherit(written);
|
|
1538
|
+
if (mode !== written && mode !== undefined) restated++;
|
|
1539
|
+
entry.v = mode ?? (written as JsonObject[string]);
|
|
1540
|
+
for (const field of Object.keys(key)) {
|
|
1541
|
+
if (field === 'time' || field === INHERIT_TRACK.field) continue;
|
|
1542
|
+
note('blocker', 'TIMELINE_FIELD', where, `key field "${field}" is not part of this timeline's shape`);
|
|
1543
|
+
}
|
|
1544
|
+
return entry;
|
|
1545
|
+
});
|
|
1546
|
+
if (restated > 0) {
|
|
1547
|
+
note(
|
|
1548
|
+
'lossy',
|
|
1549
|
+
'TIMELINE_KEY_RESTATED',
|
|
1550
|
+
where,
|
|
1551
|
+
`${restated} key(s) omit the mode or spell it with a capital first letter, and are written out as the ` +
|
|
1552
|
+
`mode the runtime reads there (an omitted one is ${INHERIT_TRACK.dflt}) — the same mode, in the ` +
|
|
1553
|
+
'spelling the editor writes',
|
|
1554
|
+
);
|
|
1555
|
+
}
|
|
1556
|
+
tracks.push({ bone, property, keys: out });
|
|
1557
|
+
continue;
|
|
1558
|
+
}
|
|
1483
1559
|
if (shape === undefined) {
|
|
1484
1560
|
note(
|
|
1485
1561
|
'blocker',
|
|
1486
1562
|
'BONE_TIMELINE',
|
|
1487
1563
|
where,
|
|
1488
|
-
`timeline "${property}" has no track in the motion spec — a bone track is ${
|
|
1564
|
+
`timeline "${property}" has no track in the motion spec — a bone track is ${BONE_TRACK_NAMES.join(', ')} ` +
|
|
1489
1565
|
'and nothing else, so the rebuild plays nothing here',
|
|
1490
1566
|
);
|
|
1491
1567
|
continue;
|
|
@@ -1667,6 +1743,26 @@ function ingestAnimation(
|
|
|
1667
1743
|
});
|
|
1668
1744
|
}
|
|
1669
1745
|
|
|
1746
|
+
for (const { property, lastKey } of unreached) {
|
|
1747
|
+
const flag = `${property}Global`;
|
|
1748
|
+
note(
|
|
1749
|
+
'lossy',
|
|
1750
|
+
'PHYSICS_GLOBAL_REACHES_NOTHING',
|
|
1751
|
+
`animation "${animName}" physics "" ${property}`,
|
|
1752
|
+
'names no constraint, so the runtime writes it into every physics constraint ' +
|
|
1753
|
+
(property === 'reset' ? 'the skeleton has' : `declaring "${flag}"`) +
|
|
1754
|
+
', and the rebuild carries ' +
|
|
1755
|
+
(carriedPhysics.length === 0
|
|
1756
|
+
? 'no physics constraint'
|
|
1757
|
+
: `none that does (${carriedPhysics.map((one) => `"${String(one.name)}"`).join(', ')})`) +
|
|
1758
|
+
` — it moves nothing, and \`build\` would refuse its \`"physics": "${EVERY_GLOBAL_PHYSICS}"\` track by name. ` +
|
|
1759
|
+
'The motion spec omits it' +
|
|
1760
|
+
(lastKey > maxT
|
|
1761
|
+
? `, and an animation's duration is the last key it has left, so the rebuilt animation ends at ${maxT}s rather than ${lastKey}s`
|
|
1762
|
+
: '; the rebuild differs from the source by exactly this no-op'),
|
|
1763
|
+
);
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1670
1766
|
for (const group of Object.keys(anim)) {
|
|
1671
1767
|
if (ANIMATION_GROUPS.includes(group)) continue;
|
|
1672
1768
|
note(
|
package/src/motion.ts
CHANGED
|
@@ -59,6 +59,24 @@ import type { MotionSpec } from './types.ts';
|
|
|
59
59
|
|
|
60
60
|
export const MOTION_SPEC_VERSION = 'rigc-motion/1';
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* A track's `physics` target for the timeline that names NO constraint (issue
|
|
64
|
+
* #726) — the one the runtime applies to every physics constraint whose own data
|
|
65
|
+
* declares the keyed property global (`"strengthGlobal": true` for `strength`,
|
|
66
|
+
* and so on; `reset` resets every physics constraint and asks no flag).
|
|
67
|
+
* `compile` emits it under the empty name, which is the skeleton file's own
|
|
68
|
+
* spelling of it (`SkeletonJson.js:1048-1054`).
|
|
69
|
+
*
|
|
70
|
+
* 🔑 Not the empty string itself, and that is the choice rather than a detail:
|
|
71
|
+
* `""` is the likeliest shape of a value somebody forgot to fill in, and a
|
|
72
|
+
* forgotten target that silently became "every global constraint" is the exact
|
|
73
|
+
* silence this format exists to name. So `"physics": ""` is refused by name and
|
|
74
|
+
* points here, and `"*"` is reserved the other way round: `compile` refuses a
|
|
75
|
+
* physics constraint that is CALLED `"*"`, because a track naming it could not
|
|
76
|
+
* say which of the two it meant.
|
|
77
|
+
*/
|
|
78
|
+
export const EVERY_GLOBAL_PHYSICS = '*';
|
|
79
|
+
|
|
62
80
|
/**
|
|
63
81
|
* The six fields that pick a track's target family. Listed here as well as in
|
|
64
82
|
* `compile`'s `resolveTargets` because the two ask different questions of it:
|
|
@@ -292,7 +310,15 @@ function parsePhysics(raw: unknown, where: string): void {
|
|
|
292
310
|
const table = needObj(raw, where, 'physics', 'it is a table keyed by constraint name, `{ "<name>": { "bone": … } }`');
|
|
293
311
|
for (const [name, entry] of Object.entries(table)) {
|
|
294
312
|
const key = `physics."${name}"`;
|
|
295
|
-
|
|
313
|
+
if (name === EVERY_GLOBAL_PHYSICS) {
|
|
314
|
+
throw new CompileError(
|
|
315
|
+
`${where}: \`${key}\` names a physics constraint "${EVERY_GLOBAL_PHYSICS}", and that name is reserved: a ` +
|
|
316
|
+
`track's \`"physics": "${EVERY_GLOBAL_PHYSICS}"\` is the timeline that names no constraint and drives ` +
|
|
317
|
+
'every one declaring the keyed property global, so a constraint called ' +
|
|
318
|
+
`"${EVERY_GLOBAL_PHYSICS}" could not be keyed by name. Give it another name`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
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
322
|
known(spec, 'MotionPhysics', where, key);
|
|
297
323
|
needString(spec.bone, where, `${key}.bone`, 'a physics constraint drives one bone, named here');
|
|
298
324
|
for (const field of PHYSICS_NUMBERS) {
|
|
@@ -378,6 +404,17 @@ function parseTracks(raw: unknown, where: string, at: string): void {
|
|
|
378
404
|
for (const field of TARGET_FIELDS) {
|
|
379
405
|
optString(track[field], where, `${key}.${field}`, `a track's "${field}" is the name of the ${field === 'slot' || field === 'bone' ? field : `${field} it targets`}`);
|
|
380
406
|
}
|
|
407
|
+
if (track.physics === '') {
|
|
408
|
+
refuse(
|
|
409
|
+
where,
|
|
410
|
+
`${key}.physics`,
|
|
411
|
+
track.physics,
|
|
412
|
+
'the empty name is how a skeleton file spells a physics timeline that names no constraint, and a motion ' +
|
|
413
|
+
`spec spells that "${EVERY_GLOBAL_PHYSICS}" — it drives every physics constraint that declares the keyed ` +
|
|
414
|
+
'property global (`"strengthGlobal": true` for `strength`). Name one constraint, or write ' +
|
|
415
|
+
`"${EVERY_GLOBAL_PHYSICS}"`,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
381
418
|
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
419
|
optFinite(track.stagger, where, `${key}.stagger`, '"stagger" is the extra per-member delay inside a group, in seconds, so a finite number');
|
|
383
420
|
const keys = needArray(track.keys, where, `${key}.keys`, 'it is an array of `{ t, v }` keys');
|
package/src/rig.ts
CHANGED
|
@@ -162,6 +162,38 @@ export const RIG_BONE_INHERIT: readonly RigBoneInherit[] = [
|
|
|
162
162
|
'noScaleOrReflection',
|
|
163
163
|
];
|
|
164
164
|
|
|
165
|
+
/**
|
|
166
|
+
* The mode a spelling of `inherit` resolves to, in the table's own spelling —
|
|
167
|
+
* or `undefined` for one the runtime cannot resolve.
|
|
168
|
+
*
|
|
169
|
+
* ⭐ **One rule for both places the format spells a mode**: a bone's setup
|
|
170
|
+
* `inherit` and an `inherit` timeline key are read by the same call,
|
|
171
|
+
* `Utils.enumValue(Inherit, name)`, which is `Inherit[name[0].toUpperCase() +
|
|
172
|
+
* name.slice(1)]` — the FIRST letter is folded and nothing else. So `noScale`
|
|
173
|
+
* and `NoScale` resolve and `NOSCALE` or `noscale` do not, and a spelling that
|
|
174
|
+
* misses loads as `undefined`: the setup pose holds no mode at all and a
|
|
175
|
+
* timeline frame holds NaN, and in both cases `updateWorldTransform`'s switch
|
|
176
|
+
* matches no case and leaves the world matrix where it was. Nothing throws.
|
|
177
|
+
*
|
|
178
|
+
* 🚨 The setup check was **case-insensitive** until issue #733, which is wider
|
|
179
|
+
* than the runtime's rule by exactly that silence: `"inherit": "NOSCALE"`
|
|
180
|
+
* compiled, gated green on all 45 assertions, and loaded `setupPose.inherit ===
|
|
181
|
+
* undefined`. Measured, not argued — and a key read through the same wide rule
|
|
182
|
+
* would have shipped the same spelling into a timeline.
|
|
183
|
+
*/
|
|
184
|
+
export function resolveBoneInherit(value: unknown): RigBoneInherit | undefined {
|
|
185
|
+
if (typeof value !== 'string' || value.length === 0) return undefined;
|
|
186
|
+
const folded = value[0].toLowerCase() + value.slice(1);
|
|
187
|
+
return RIG_BONE_INHERIT.find((mode) => mode === folded);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The value REQUIRED, as both refusals of an unresolvable mode print it: the
|
|
192
|
+
* five, and the one liberty the runtime's lookup allows.
|
|
193
|
+
*/
|
|
194
|
+
export const BONE_INHERIT_KNOWN =
|
|
195
|
+
`known: ${RIG_BONE_INHERIT.join(', ')} — the runtime folds the case of the first letter and of nothing else`;
|
|
196
|
+
|
|
165
197
|
/**
|
|
166
198
|
* Take a bone's setup transform from the cut manifest rather than from a literal.
|
|
167
199
|
*
|
|
@@ -1802,9 +1834,9 @@ export function parseRigSpec(raw: unknown, where: string): RigSpec {
|
|
|
1802
1834
|
`${where}: bone "${bone.name}" names parent ${JSON.stringify(bone.parent)}, which is not declared before it`,
|
|
1803
1835
|
);
|
|
1804
1836
|
}
|
|
1805
|
-
if (bone.inherit !== undefined &&
|
|
1837
|
+
if (bone.inherit !== undefined && resolveBoneInherit(bone.inherit) === undefined) {
|
|
1806
1838
|
throw new CompileError(
|
|
1807
|
-
`${where}: bone "${bone.name}" has inherit ${JSON.stringify(bone.inherit)};
|
|
1839
|
+
`${where}: bone "${bone.name}" has inherit ${JSON.stringify(bone.inherit)}; ${BONE_INHERIT_KNOWN}`,
|
|
1808
1840
|
);
|
|
1809
1841
|
}
|
|
1810
1842
|
const from = bone.from;
|