spine-rigc 1.2.3 → 1.4.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 +2 -2
- package/cli.ts +124 -20
- package/docs/AUTHORING.md +471 -19
- package/docs/FACE.md +15 -0
- package/docs/INGEST.md +1 -1
- package/package.json +17 -1
- package/src/atlas.ts +151 -2
- package/src/compile.ts +335 -0
- package/src/mesh.ts +405 -1
- package/src/pose.ts +297 -10
- package/src/render.ts +376 -21
- package/src/rig.ts +140 -7
- package/src/types.ts +30 -0
- package/src/validate.ts +101 -24
package/src/render.ts
CHANGED
|
@@ -336,6 +336,17 @@ export interface PoseOptions {
|
|
|
336
336
|
* one instrument that does (`bonedist.ts`) needs it on every frame.
|
|
337
337
|
*/
|
|
338
338
|
bones?: boolean;
|
|
339
|
+
/**
|
|
340
|
+
* Also record every slot's attachment geometry, whole — see `AttachmentPose`
|
|
341
|
+
* and `Frame.attachments` (issue #864). Off by default for `bones`' reason:
|
|
342
|
+
* nothing that draws reads it, and `render --geometry` is the one caller.
|
|
343
|
+
*
|
|
344
|
+
* ⭐ **It is not filtered by `slots`/`hidden` and not cut by a clip.** Those
|
|
345
|
+
* are statements about which pixels are drawn; the geometry is a statement
|
|
346
|
+
* about where the pose put each attachment, and it is the same pose whatever
|
|
347
|
+
* a picture of it leaves out.
|
|
348
|
+
*/
|
|
349
|
+
geometry?: boolean;
|
|
339
350
|
/**
|
|
340
351
|
* Pose under this skin, by the name the skeleton declares for it.
|
|
341
352
|
*
|
|
@@ -635,6 +646,44 @@ export interface Frame {
|
|
|
635
646
|
* the two rigs.
|
|
636
647
|
*/
|
|
637
648
|
bones?: BoneSnapshot[];
|
|
649
|
+
/**
|
|
650
|
+
* Every slot's attachment as the pose left it, in draw order — present only
|
|
651
|
+
* when `PoseOptions.geometry` asked for it (issue #864).
|
|
652
|
+
*
|
|
653
|
+
* ⚠️ Not `pieces` again. A piece is what gets DRAWN: `--slot`/`--hide` remove
|
|
654
|
+
* pieces and a clip replaces one with the clipper's own triangle list, whose
|
|
655
|
+
* vertices are not the attachment's and are not numbered like them. A
|
|
656
|
+
* consumer comparing a triangle's edges across frames needs vertex `i` to be
|
|
657
|
+
* the same vertex in every frame, so these are the attachment's own vertices,
|
|
658
|
+
* whole, for every slot that shows a region or a mesh.
|
|
659
|
+
*
|
|
660
|
+
* Read off the same skeleton at the same step as `pieces` and `bones`, which
|
|
661
|
+
* is what puts it on render's frame grid by construction rather than by a
|
|
662
|
+
* second derivation of it.
|
|
663
|
+
*/
|
|
664
|
+
attachments?: AttachmentPose[];
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* One slot's region or mesh attachment in one posed frame (issue #864).
|
|
669
|
+
*
|
|
670
|
+
* `vertices` come from the runtime's own `computeWorldVertices` over the whole
|
|
671
|
+
* attachment — the call `pieceOf` makes, through the one helper both share —
|
|
672
|
+
* so skinning and deform live in spine-core and nowhere here.
|
|
673
|
+
*/
|
|
674
|
+
export interface AttachmentPose {
|
|
675
|
+
slot: string;
|
|
676
|
+
/** The attachment's own name, which is what a deform or attachment timeline keys. */
|
|
677
|
+
attachment: string;
|
|
678
|
+
/**
|
|
679
|
+
* World positions, `x, y` per vertex, **y up**. A region's four corners are in
|
|
680
|
+
* spine-core's order — bottom-left, top-left, top-right, bottom-right — and a
|
|
681
|
+
* mesh's vertices in the attachment's own order, so index `i` names the same
|
|
682
|
+
* vertex in every frame.
|
|
683
|
+
*/
|
|
684
|
+
vertices: number[];
|
|
685
|
+
/** Slot colour x attachment colour, straight alpha, 0..1 — the piece's `tint`, by the same arithmetic. */
|
|
686
|
+
color: [number, number, number, number];
|
|
638
687
|
}
|
|
639
688
|
|
|
640
689
|
/** Where the world sits in a frame: the four world numbers plus the scale. */
|
|
@@ -732,6 +781,7 @@ export function sampleAnimation(data: SkeletonData, name: string, fps: number, o
|
|
|
732
781
|
time: i * step,
|
|
733
782
|
pieces: piecesOf(skeleton, pieceOpts),
|
|
734
783
|
...(opts?.bones ? { bones: boneSnapshots(skeleton) } : {}),
|
|
784
|
+
...(opts?.geometry ? { attachments: attachmentsOf(skeleton) } : {}),
|
|
735
785
|
});
|
|
736
786
|
}
|
|
737
787
|
return frames;
|
|
@@ -746,20 +796,31 @@ export function sampleAnimation(data: SkeletonData, name: string, fps: number, o
|
|
|
746
796
|
* whole content is the setup pose.
|
|
747
797
|
*/
|
|
748
798
|
export function sampleSetupPose(data: SkeletonData, opts?: PoseOptions): Frame[] {
|
|
749
|
-
const skeleton =
|
|
750
|
-
skeleton.setupPose();
|
|
751
|
-
skeleton.update(0);
|
|
752
|
-
skeleton.updateWorldTransform(Physics.reset);
|
|
799
|
+
const skeleton = setupPosed(data, opts?.skin);
|
|
753
800
|
return [
|
|
754
801
|
{
|
|
755
802
|
index: 0,
|
|
756
803
|
time: 0,
|
|
757
804
|
pieces: piecesOf(skeleton, piecesOptions(opts)),
|
|
758
805
|
...(opts?.bones ? { bones: boneSnapshots(skeleton) } : {}),
|
|
806
|
+
...(opts?.geometry ? { attachments: attachmentsOf(skeleton) } : {}),
|
|
759
807
|
},
|
|
760
808
|
];
|
|
761
809
|
}
|
|
762
810
|
|
|
811
|
+
/**
|
|
812
|
+
* A fresh skeleton under `skin`, stepped into its setup pose — the one recipe
|
|
813
|
+
* `sampleSetupPose` and the geometry export's `rest` table both pose from, so
|
|
814
|
+
* the two cannot come to describe different rest poses.
|
|
815
|
+
*/
|
|
816
|
+
function setupPosed(data: SkeletonData, skin: string | undefined): Skeleton {
|
|
817
|
+
const skeleton = skeletonUnderSkin(data, skin);
|
|
818
|
+
skeleton.setupPose();
|
|
819
|
+
skeleton.update(0);
|
|
820
|
+
skeleton.updateWorldTransform(Physics.reset);
|
|
821
|
+
return skeleton;
|
|
822
|
+
}
|
|
823
|
+
|
|
763
824
|
/**
|
|
764
825
|
* Every animation of one skeleton at one rate, keyed by the name its frames are
|
|
765
826
|
* filed under. A skeleton with no animation at all contributes its setup pose
|
|
@@ -877,14 +938,7 @@ function pieceOf(
|
|
|
877
938
|
'the attachment names a region the atlas does not have',
|
|
878
939
|
);
|
|
879
940
|
}
|
|
880
|
-
const
|
|
881
|
-
const own = attachment.color;
|
|
882
|
-
const tint: [number, number, number, number] = [
|
|
883
|
-
colour.r * own.r,
|
|
884
|
-
colour.g * own.g,
|
|
885
|
-
colour.b * own.b,
|
|
886
|
-
colour.a * own.a,
|
|
887
|
-
];
|
|
941
|
+
const tint = tintOf(slot, attachment);
|
|
888
942
|
// The dark colour is the SLOT's alone — an attachment has a `color` and no
|
|
889
943
|
// dark one, so there is nothing to multiply it by. Read off `appliedPose`
|
|
890
944
|
// like the light colour, so an `rgba2` timeline reaches the picture.
|
|
@@ -897,18 +951,11 @@ function pieceOf(
|
|
|
897
951
|
|
|
898
952
|
let piece: Piece;
|
|
899
953
|
let triangles: number[];
|
|
954
|
+
const world = worldVerticesOf(skeleton, slot, attachment);
|
|
900
955
|
if (isMesh) {
|
|
901
|
-
// `worldVerticesLength` is 2 per vertex whether or not the mesh is
|
|
902
|
-
// weighted — the weight runs live in `vertices`, not here — so this is the
|
|
903
|
-
// full output length and the whole mesh is computed in one call. Deform
|
|
904
|
-
// offsets, if the pose carries any, are applied inside it.
|
|
905
|
-
const world = new Array<number>(attachment.worldVerticesLength).fill(0);
|
|
906
|
-
attachment.computeWorldVertices(skeleton, slot, 0, attachment.worldVerticesLength, world, 0, 2);
|
|
907
956
|
triangles = attachment.triangles;
|
|
908
957
|
piece = { kind: 'mesh', ...common, texture, world, uvs, triangles };
|
|
909
958
|
} else {
|
|
910
|
-
const world = new Array<number>(8).fill(0);
|
|
911
|
-
attachment.computeWorldVertices(slot, attachment.getOffsets(pose), world, 0, 2);
|
|
912
959
|
triangles = QUAD_TRIANGLES;
|
|
913
960
|
piece = { kind: 'region', ...common, texture, world, uvs };
|
|
914
961
|
}
|
|
@@ -916,6 +963,56 @@ function pieceOf(
|
|
|
916
963
|
return clippedPiece(piece, triangles, uvs, clipper);
|
|
917
964
|
}
|
|
918
965
|
|
|
966
|
+
/** Slot colour x attachment colour, straight alpha — what a piece is tinted by and a geometry entry records. */
|
|
967
|
+
function tintOf(slot: Slot, attachment: MeshAttachment | RegionAttachment): [number, number, number, number] {
|
|
968
|
+
const colour = slot.appliedPose.color;
|
|
969
|
+
const own = attachment.color;
|
|
970
|
+
return [colour.r * own.r, colour.g * own.g, colour.b * own.b, colour.a * own.a];
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* One attachment's world vertices on `slot`, whole, by the runtime's own routine.
|
|
975
|
+
*
|
|
976
|
+
* The one place either kind is asked for them: `pieceOf` draws what this returns
|
|
977
|
+
* and `attachmentsOf` records it, so a drawn frame and its geometry export
|
|
978
|
+
* cannot disagree about where a vertex is.
|
|
979
|
+
*
|
|
980
|
+
* `worldVerticesLength` is 2 per vertex whether or not the mesh is weighted —
|
|
981
|
+
* the weight runs live in `vertices`, not here — so this is the full output
|
|
982
|
+
* length and the whole mesh is computed in one call. Deform offsets, if the
|
|
983
|
+
* slot's pose carries any, are applied inside it; a region's offsets are read
|
|
984
|
+
* for the sequence frame the slot's pose resolves.
|
|
985
|
+
*/
|
|
986
|
+
function worldVerticesOf(skeleton: Skeleton, slot: Slot, attachment: MeshAttachment | RegionAttachment): number[] {
|
|
987
|
+
if (attachment instanceof MeshAttachment) {
|
|
988
|
+
const world = new Array<number>(attachment.worldVerticesLength).fill(0);
|
|
989
|
+
attachment.computeWorldVertices(skeleton, slot, 0, attachment.worldVerticesLength, world, 0, 2);
|
|
990
|
+
return world;
|
|
991
|
+
}
|
|
992
|
+
const world = new Array<number>(8).fill(0);
|
|
993
|
+
attachment.computeWorldVertices(slot, attachment.getOffsets(slot.appliedPose), world, 0, 2);
|
|
994
|
+
return world;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Every slot's region or mesh attachment, whole, in the posed draw order — see
|
|
999
|
+
* `Frame.attachments`. No subset and no clip: those are `piecesOf`'s business.
|
|
1000
|
+
*/
|
|
1001
|
+
function attachmentsOf(skeleton: Skeleton): AttachmentPose[] {
|
|
1002
|
+
const out: AttachmentPose[] = [];
|
|
1003
|
+
for (const slot of skeleton.drawOrder.appliedPose) {
|
|
1004
|
+
const attachment = slot.appliedPose.attachment;
|
|
1005
|
+
if (!(attachment instanceof MeshAttachment) && !(attachment instanceof RegionAttachment)) continue;
|
|
1006
|
+
out.push({
|
|
1007
|
+
slot: slot.data.name,
|
|
1008
|
+
attachment: attachment.name,
|
|
1009
|
+
vertices: worldVerticesOf(skeleton, slot, attachment),
|
|
1010
|
+
color: tintOf(slot, attachment),
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
return out;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
919
1016
|
/**
|
|
920
1017
|
* `piece` as the active clip leaves it, or `piece` itself when the clipper cut
|
|
921
1018
|
* nothing — see `piecesOf`.
|
|
@@ -951,6 +1048,262 @@ function clippedPiece(piece: Piece, triangles: number[], uvs: Float32Array, clip
|
|
|
951
1048
|
return { kind: 'mesh', tint, dark, slot, page, texture, world, uvs: clippedUvs, triangles: clipped };
|
|
952
1049
|
}
|
|
953
1050
|
|
|
1051
|
+
// ---------------------------------------------------------------------------
|
|
1052
|
+
// the geometry export — `render --geometry` (issue #864)
|
|
1053
|
+
// ---------------------------------------------------------------------------
|
|
1054
|
+
//
|
|
1055
|
+
// ⭐ A frame set is pixels, and two judgements a consumer that does not link
|
|
1056
|
+
// spine-core wants to make are not about pixels: how far a mesh triangle is
|
|
1057
|
+
// stretched over its rest shape, and whether a region holds still in its own
|
|
1058
|
+
// bone's frame. Both need the numbers the pose was drawn FROM. So `render` writes
|
|
1059
|
+
// them beside the pictures, off the very `Frame`s it drew — one call, one frame
|
|
1060
|
+
// grid, one viewport — rather than a second command re-deriving any of the three.
|
|
1061
|
+
|
|
1062
|
+
/** The export's file name inside an animation's frame directory, and its format tag. */
|
|
1063
|
+
export const GEOMETRY_FILE = 'geometry.json';
|
|
1064
|
+
export const GEOMETRY_SPEC = 'rigc-geometry/1';
|
|
1065
|
+
/** Stated in the file, so a reader cannot take the numbers for frame pixels. */
|
|
1066
|
+
export const GEOMETRY_COORDINATES = 'spine world, y up, world units';
|
|
1067
|
+
|
|
1068
|
+
/** A geometry file's frame: `Frame` reduced to the numbers the export promises. */
|
|
1069
|
+
export interface GeometryFrame {
|
|
1070
|
+
index: number;
|
|
1071
|
+
time: number;
|
|
1072
|
+
bones: GeometryBone[];
|
|
1073
|
+
attachments: AttachmentPose[];
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/** One bone's world transform: `world = [a b; c d]·local + (worldX, worldY)`. */
|
|
1077
|
+
export interface GeometryBone {
|
|
1078
|
+
name: string;
|
|
1079
|
+
a: number;
|
|
1080
|
+
b: number;
|
|
1081
|
+
c: number;
|
|
1082
|
+
d: number;
|
|
1083
|
+
worldX: number;
|
|
1084
|
+
worldY: number;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* One attachment's rest geometry and its topology — the half of a stretch ratio
|
|
1089
|
+
* no frame carries.
|
|
1090
|
+
*
|
|
1091
|
+
* ⭐ **Rest is the setup pose's bones with no deform**, taken for every
|
|
1092
|
+
* attachment any frame of the file shows — including one the setup pose does not
|
|
1093
|
+
* show, which a slot only swaps to later. For an attachment the setup pose does
|
|
1094
|
+
* show, these vertices are the `setup` entry's own, bit for bit: both come off
|
|
1095
|
+
* one skeleton posed by `setupPosed`.
|
|
1096
|
+
*/
|
|
1097
|
+
export interface AttachmentRest {
|
|
1098
|
+
slot: string;
|
|
1099
|
+
attachment: string;
|
|
1100
|
+
kind: 'region' | 'mesh';
|
|
1101
|
+
vertices: number[];
|
|
1102
|
+
/** Vertex index triplets. A region's are the runtime's own `0 1 2 2 3 0`. */
|
|
1103
|
+
triangles: number[];
|
|
1104
|
+
/** A mesh's hull vertex count — the first `hull` vertices, as the format's `hull` field counts them. */
|
|
1105
|
+
hull?: number;
|
|
1106
|
+
/** A mesh's `uvs`, `u, v` per vertex over the untrimmed drawing, y down — the attachment's own, not a page's. */
|
|
1107
|
+
uvs?: number[];
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
export interface GeometryFile {
|
|
1111
|
+
spec: string;
|
|
1112
|
+
coordinates: string;
|
|
1113
|
+
/** The animation, or `null` for a skeleton with none (its one frame is the setup pose). */
|
|
1114
|
+
animation: string | null;
|
|
1115
|
+
skin?: string;
|
|
1116
|
+
fps: number;
|
|
1117
|
+
/** The box the PNG frames beside this file were drawn over — `frames.json`'s own `viewport`. */
|
|
1118
|
+
viewport: FramesSidecar['viewport'];
|
|
1119
|
+
/** Every bone in the skeleton's declaration order, and its parent's name. */
|
|
1120
|
+
bones: Array<{ name: string; parent: string | null }>;
|
|
1121
|
+
/**
|
|
1122
|
+
* Every slot in the skeleton's declaration order, and the bone it hangs from —
|
|
1123
|
+
* which is the bone whose frame "still in its own bone's frame" is read in.
|
|
1124
|
+
*/
|
|
1125
|
+
slots: Array<{ name: string; bone: string }>;
|
|
1126
|
+
rest: AttachmentRest[];
|
|
1127
|
+
/** The setup pose, sampled the way `sampleSetupPose` samples it. */
|
|
1128
|
+
setup: Omit<GeometryFrame, 'index' | 'time'>;
|
|
1129
|
+
frames: GeometryFrame[];
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* A number the export could not print as a number — refused rather than written,
|
|
1134
|
+
* because `JSON.stringify` writes `NaN` and `Infinity` as `null` and a consumer
|
|
1135
|
+
* would read a hole in the geometry as a vertex at nothing.
|
|
1136
|
+
*/
|
|
1137
|
+
export class GeometryError extends Error {}
|
|
1138
|
+
|
|
1139
|
+
/** `frames.json`'s viewport block for `v` — the one spelling both files use. */
|
|
1140
|
+
export function sidecarViewport(v: Viewport): FramesSidecar['viewport'] {
|
|
1141
|
+
return {
|
|
1142
|
+
x: v.minX,
|
|
1143
|
+
y: v.minY,
|
|
1144
|
+
width: v.maxX - v.minX,
|
|
1145
|
+
height: v.maxY - v.minY,
|
|
1146
|
+
scale: v.scale,
|
|
1147
|
+
pixelWidth: v.width,
|
|
1148
|
+
pixelHeight: v.height,
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* The geometry file for one frame set — `frames` exactly as a sampler returned
|
|
1154
|
+
* them with `{ bones: true, geometry: true }`, which is what makes its grid the
|
|
1155
|
+
* frame set's own.
|
|
1156
|
+
*
|
|
1157
|
+
* Refused by `GeometryError`, naming the frame, the slot, the attachment and the
|
|
1158
|
+
* vertex (or the bone), when any number in it is not finite.
|
|
1159
|
+
*/
|
|
1160
|
+
export function geometryFileOf(
|
|
1161
|
+
data: SkeletonData,
|
|
1162
|
+
animation: string | null,
|
|
1163
|
+
fps: number,
|
|
1164
|
+
frames: Frame[],
|
|
1165
|
+
viewport: Viewport,
|
|
1166
|
+
skin: string | undefined,
|
|
1167
|
+
): GeometryFile {
|
|
1168
|
+
const geometryFrame = (frame: Frame, where: string): Omit<GeometryFrame, 'index' | 'time'> => {
|
|
1169
|
+
if (frame.bones === undefined || frame.attachments === undefined) {
|
|
1170
|
+
throw new Error(`${where} was sampled without { bones: true, geometry: true }; the geometry export needs both`);
|
|
1171
|
+
}
|
|
1172
|
+
return {
|
|
1173
|
+
bones: frame.bones.map(({ name, a, b, c, d, worldX, worldY }) => ({ name, a, b, c, d, worldX, worldY })),
|
|
1174
|
+
attachments: frame.attachments,
|
|
1175
|
+
};
|
|
1176
|
+
};
|
|
1177
|
+
const posed = frames.map((frame) => ({
|
|
1178
|
+
index: frame.index,
|
|
1179
|
+
time: frame.time,
|
|
1180
|
+
...geometryFrame(frame, `frame ${frame.index}`),
|
|
1181
|
+
}));
|
|
1182
|
+
const setupFrame = sampleSetupPose(data, { ...(skin === undefined ? {} : { skin }), bones: true, geometry: true })[0];
|
|
1183
|
+
const setup = geometryFrame(setupFrame, 'the setup pose');
|
|
1184
|
+
const file: GeometryFile = {
|
|
1185
|
+
spec: GEOMETRY_SPEC,
|
|
1186
|
+
coordinates: GEOMETRY_COORDINATES,
|
|
1187
|
+
animation,
|
|
1188
|
+
...(skin === undefined ? {} : { skin }),
|
|
1189
|
+
fps,
|
|
1190
|
+
viewport: sidecarViewport(viewport),
|
|
1191
|
+
bones: data.bones.map((bone) => ({ name: bone.name, parent: bone.parent?.name ?? null })),
|
|
1192
|
+
slots: data.slots.map((slot) => ({ name: slot.name, bone: slot.boneData.name })),
|
|
1193
|
+
rest: restOf(data, skin, [setup.attachments, ...posed.map((frame) => frame.attachments)]),
|
|
1194
|
+
setup,
|
|
1195
|
+
frames: posed,
|
|
1196
|
+
};
|
|
1197
|
+
refuseNonFinite(file);
|
|
1198
|
+
return file;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* The rest table: every (slot, attachment) the given frames show, in order of
|
|
1203
|
+
* first appearance, posed on one setup skeleton with the slot's deform empty.
|
|
1204
|
+
*
|
|
1205
|
+
* The attachment is resolved the way the pose resolved it — `Skeleton.getAttachment`,
|
|
1206
|
+
* the skin first and the default skin second — so a name means the object the
|
|
1207
|
+
* frames drew. A region's rest corners are read for the sequence frame the setup
|
|
1208
|
+
* pose resolves, which is the frame its setup pose draws.
|
|
1209
|
+
*/
|
|
1210
|
+
function restOf(data: SkeletonData, skin: string | undefined, shown: AttachmentPose[][]): AttachmentRest[] {
|
|
1211
|
+
const skeleton = setupPosed(data, skin);
|
|
1212
|
+
// Slot, then attachment: two maps rather than one joined key, so no pair of
|
|
1213
|
+
// names can fold into another's entry whatever characters they carry.
|
|
1214
|
+
const seen = new Map<string, Set<string>>();
|
|
1215
|
+
const out: AttachmentRest[] = [];
|
|
1216
|
+
for (const entries of shown) {
|
|
1217
|
+
for (const entry of entries) {
|
|
1218
|
+
const names = seen.get(entry.slot) ?? new Set<string>();
|
|
1219
|
+
if (names.has(entry.attachment)) continue;
|
|
1220
|
+
names.add(entry.attachment);
|
|
1221
|
+
seen.set(entry.slot, names);
|
|
1222
|
+
const slotIndex = data.findSlot(entry.slot)?.index ?? -1;
|
|
1223
|
+
const slot = skeleton.slots[slotIndex];
|
|
1224
|
+
const attachment = slot === undefined ? null : skeleton.getAttachment(slotIndex, entry.attachment);
|
|
1225
|
+
if (slot === undefined || !(attachment instanceof MeshAttachment || attachment instanceof RegionAttachment)) {
|
|
1226
|
+
throw new Error(
|
|
1227
|
+
`slot ${JSON.stringify(entry.slot)} showed attachment ${JSON.stringify(entry.attachment)} in a frame, and ` +
|
|
1228
|
+
'the setup skeleton resolves no region or mesh of that name there',
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
// A mesh reads the slot's deform array; the setup pose leaves it empty,
|
|
1232
|
+
// and emptying it here says so rather than trusting that it is.
|
|
1233
|
+
slot.appliedPose.deform.length = 0;
|
|
1234
|
+
const vertices = worldVerticesOf(skeleton, slot, attachment);
|
|
1235
|
+
out.push(
|
|
1236
|
+
attachment instanceof MeshAttachment
|
|
1237
|
+
? {
|
|
1238
|
+
slot: entry.slot,
|
|
1239
|
+
attachment: entry.attachment,
|
|
1240
|
+
kind: 'mesh',
|
|
1241
|
+
vertices,
|
|
1242
|
+
triangles: Array.from(attachment.triangles),
|
|
1243
|
+
hull: attachment.hullLength / 2,
|
|
1244
|
+
uvs: Array.from(attachment.regionUVs),
|
|
1245
|
+
}
|
|
1246
|
+
: { slot: entry.slot, attachment: entry.attachment, kind: 'region', vertices, triangles: [...QUAD_TRIANGLES] },
|
|
1247
|
+
);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
return out;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/** Throw a `GeometryError` at the first number in `file` that is not finite, naming where it sits. */
|
|
1254
|
+
function refuseNonFinite(file: GeometryFile): void {
|
|
1255
|
+
const check = (where: string, entries: AttachmentPose[] | AttachmentRest[], bones: GeometryBone[]): void => {
|
|
1256
|
+
for (const bone of bones) {
|
|
1257
|
+
for (const field of ['a', 'b', 'c', 'd', 'worldX', 'worldY'] as const) {
|
|
1258
|
+
if (!Number.isFinite(bone[field])) {
|
|
1259
|
+
throw new GeometryError(`${where}: bone ${JSON.stringify(bone.name)} has ${field} ${String(bone[field])}; a world transform is finite`);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
for (const entry of entries) {
|
|
1264
|
+
const bad = entry.vertices.findIndex((value) => !Number.isFinite(value));
|
|
1265
|
+
if (bad === -1) continue;
|
|
1266
|
+
throw new GeometryError(
|
|
1267
|
+
`${where}: slot ${JSON.stringify(entry.slot)} attachment ${JSON.stringify(entry.attachment)} vertex ` +
|
|
1268
|
+
`${Math.floor(bad / 2)} has ${bad % 2 === 0 ? 'x' : 'y'} ${String(entry.vertices[bad])}; a posed vertex is finite`,
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
};
|
|
1272
|
+
check('the rest table', file.rest, []);
|
|
1273
|
+
check('the setup pose', file.setup.attachments, file.setup.bones);
|
|
1274
|
+
for (const frame of file.frames) check(`frame ${frame.index}`, frame.attachments, frame.bones);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/**
|
|
1278
|
+
* The file's text: a JSON object whose header fields sit one per line and whose
|
|
1279
|
+
* `rest` entries and `frames` sit one per line each, so a diff of two exports
|
|
1280
|
+
* names the frame that moved.
|
|
1281
|
+
*
|
|
1282
|
+
* Numbers are `JSON.stringify`'s, which is how every JSON this tree writes prints
|
|
1283
|
+
* them — the shortest decimal that reads back as the same double, fixed by the
|
|
1284
|
+
* language rather than a locale — so a vertex in the file IS the runtime's
|
|
1285
|
+
* vertex, not a rounding of it.
|
|
1286
|
+
*/
|
|
1287
|
+
export function geometryText(file: GeometryFile): string {
|
|
1288
|
+
const lines: string[] = [];
|
|
1289
|
+
const entries = Object.entries(file);
|
|
1290
|
+
entries.forEach(([key, value], i) => {
|
|
1291
|
+
const comma = i + 1 < entries.length ? ',' : '';
|
|
1292
|
+
if ((key === 'rest' || key === 'frames') && Array.isArray(value)) {
|
|
1293
|
+
if (value.length === 0) {
|
|
1294
|
+
lines.push(` ${JSON.stringify(key)}: []${comma}`);
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
lines.push(` ${JSON.stringify(key)}: [`);
|
|
1298
|
+
value.forEach((item, j) => lines.push(` ${JSON.stringify(item)}${j + 1 < value.length ? ',' : ''}`));
|
|
1299
|
+
lines.push(` ]${comma}`);
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
lines.push(` ${JSON.stringify(key)}: ${JSON.stringify(value)}${comma}`);
|
|
1303
|
+
});
|
|
1304
|
+
return `{\n${lines.join('\n')}\n}\n`;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
954
1307
|
// ---------------------------------------------------------------------------
|
|
955
1308
|
// texture-only substitution — see `substituteTexture`
|
|
956
1309
|
// ---------------------------------------------------------------------------
|
|
@@ -1347,7 +1700,9 @@ export function framingViewport(data: SkeletonData, maxSide: number, opts?: Pose
|
|
|
1347
1700
|
// A clip is taken off for the same reason (issue #844): what it removes still
|
|
1348
1701
|
// counts toward the box, so adding or keying a mask moves no pixel it leaves
|
|
1349
1702
|
// drawn — see `PoseOptions.unclipped`.
|
|
1350
|
-
|
|
1703
|
+
// Neither the bone snapshots nor the geometry export frame anything, and
|
|
1704
|
+
// both would be taken at `FRAMING_FPS` for every animation only to be dropped.
|
|
1705
|
+
const { slots: _drawn, hidden: _hidden, bones: _bones, geometry: _geometry, ...whole } = opts ?? {};
|
|
1351
1706
|
const framed: PoseOptions = { ...whole, unclipped: true };
|
|
1352
1707
|
const sets =
|
|
1353
1708
|
data.animations.length === 0
|
package/src/rig.ts
CHANGED
|
@@ -557,6 +557,72 @@ export interface RigGridGenerator {
|
|
|
557
557
|
soft?: RigSoftRegion;
|
|
558
558
|
}
|
|
559
559
|
|
|
560
|
+
/**
|
|
561
|
+
* One segment stated outright, for a pull that no bone's own span describes.
|
|
562
|
+
*
|
|
563
|
+
* `from` and `to` are in the part's own pixels, y down — the frame every other
|
|
564
|
+
* point a generator takes is in (`ring.center`, `ring.hull`). The bone is
|
|
565
|
+
* resolved by name like everything else; the two points belong to this mesh
|
|
566
|
+
* alone, which is the case a bone's `length` cannot cover: two meshes over one
|
|
567
|
+
* bone that each want it to pull along a different line.
|
|
568
|
+
*/
|
|
569
|
+
export interface RigSegmentSpan {
|
|
570
|
+
bone: string;
|
|
571
|
+
from: [number, number];
|
|
572
|
+
to: [number, number];
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* How distance to a segment becomes a weight: `w = 1 / (d + radius)^power`
|
|
577
|
+
* per candidate bone, the strongest `maxBones` kept and normalised, any share
|
|
578
|
+
* under `minWeight` dropped, the rest normalised again.
|
|
579
|
+
*/
|
|
580
|
+
export interface RigSegmentsFalloff {
|
|
581
|
+
/** The exponent. Default 2. */
|
|
582
|
+
power?: number;
|
|
583
|
+
/**
|
|
584
|
+
* **Required.** Added to every distance, in the part's pixels, so a vertex
|
|
585
|
+
* ON a segment has a finite weight and the blend between two segments is as
|
|
586
|
+
* wide as this says. No default: it is a length on this part's art.
|
|
587
|
+
*/
|
|
588
|
+
radius: number;
|
|
589
|
+
/** At most this many bones pull one vertex. Default 4. */
|
|
590
|
+
maxBones?: number;
|
|
591
|
+
/** A normalised share under this is dropped. Default 0.03. */
|
|
592
|
+
minWeight?: number;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* A lattice over the part's alpha, weighted by distance to named bone segments
|
|
597
|
+
* (`buildSegmentsLattice` and `segmentWeights` in [`mesh.ts`](mesh.ts)).
|
|
598
|
+
*
|
|
599
|
+
* ⭐ The one authoring decision is `bones` — which segments may pull this part.
|
|
600
|
+
* The geometry comes off the attachment's own `image`, the segments off the
|
|
601
|
+
* skeleton's setup pose, and the weights off the distance between the two, so
|
|
602
|
+
* nothing else here is a judgement about the art.
|
|
603
|
+
*/
|
|
604
|
+
export interface RigSegmentsGenerator {
|
|
605
|
+
kind: 'segments';
|
|
606
|
+
/** **Required.** The lattice's cell, in the part's pixels, a whole number of at least 1. */
|
|
607
|
+
cell: number;
|
|
608
|
+
/**
|
|
609
|
+
* **Required, at least one entry.** Each is a bone name (origin to its
|
|
610
|
+
* `length` tip), a chain — a list of bone names, root first, each link
|
|
611
|
+
* running from its origin to the next link's (the last to its `length` tip) —
|
|
612
|
+
* or a `RigSegmentSpan` stated outright.
|
|
613
|
+
*/
|
|
614
|
+
bones: Array<string | string[] | RigSegmentSpan>;
|
|
615
|
+
/** The falloff — see `RigSegmentsFalloff`. `radius` is required, so the block is too. */
|
|
616
|
+
falloff: RigSegmentsFalloff;
|
|
617
|
+
/** Alpha at or above which a pixel counts as art, 1..255. Default 1 — `contour`'s own. */
|
|
618
|
+
alpha?: number;
|
|
619
|
+
/**
|
|
620
|
+
* Where the slot bone sits in the part's pixels, y down. Default the window's
|
|
621
|
+
* centre — the placement every generator on this route uses.
|
|
622
|
+
*/
|
|
623
|
+
anchor?: [number, number];
|
|
624
|
+
}
|
|
625
|
+
|
|
560
626
|
/**
|
|
561
627
|
* Which builder in `src/mesh.ts` makes this mesh's geometry, and its parameters.
|
|
562
628
|
*
|
|
@@ -570,10 +636,15 @@ export interface RigGridGenerator {
|
|
|
570
636
|
* and measured art lives in the manifest. `generator` is for a skeleton with no
|
|
571
637
|
* manifest behind it.
|
|
572
638
|
*/
|
|
573
|
-
export type RigMeshGenerator =
|
|
639
|
+
export type RigMeshGenerator =
|
|
640
|
+
| RigRingGenerator
|
|
641
|
+
| RigRibbonGenerator
|
|
642
|
+
| RigContourGenerator
|
|
643
|
+
| RigGridGenerator
|
|
644
|
+
| RigSegmentsGenerator;
|
|
574
645
|
|
|
575
|
-
/** The
|
|
576
|
-
export const RIG_GENERATOR_KINDS = ['ring', 'ribbon', 'contour', 'grid'] as const;
|
|
646
|
+
/** The five `kind` names a generator may carry, and the order `RIG_KEYS` takes them in. */
|
|
647
|
+
export const RIG_GENERATOR_KINDS = ['ring', 'ribbon', 'contour', 'grid', 'segments'] as const;
|
|
577
648
|
|
|
578
649
|
/**
|
|
579
650
|
* The attachment's own NAME, as distinct from the placeholder key it is filed
|
|
@@ -1591,6 +1662,35 @@ export interface RigInvariants {
|
|
|
1591
1662
|
* left to measure, and on the build's stats line when something is.
|
|
1592
1663
|
*/
|
|
1593
1664
|
consumerDrivenMix?: RigConsumerDrivenMix[];
|
|
1665
|
+
/**
|
|
1666
|
+
* The rig's `idle` deforms meshes **on purpose**, so
|
|
1667
|
+
* `A15_IDLE_NO_MESH_BONE_KEYS` reports the renderer cost instead of refusing
|
|
1668
|
+
* each keyed bone (issues #855, #858).
|
|
1669
|
+
*
|
|
1670
|
+
* 🔑 **What A15 assumes, and the genre that breaks it.** The rule exists for a
|
|
1671
|
+
* renderer that skips redrawing a mesh nothing moved, so an `idle` keying a
|
|
1672
|
+
* mesh-driving bone spends that skip on every frame — right for a rig whose
|
|
1673
|
+
* meshes are mostly static, wrong for a painting rig: one illustration
|
|
1674
|
+
* decomposed into layers, most of them weighted meshes over bone chains, with
|
|
1675
|
+
* an `idle` whose whole job is to move them (hair, sleeves, breathing). The
|
|
1676
|
+
* only way through without this field was a same-origin parent above every
|
|
1677
|
+
* keyed bone — 42 extra bones on the first such rig, the same pose, the rule's
|
|
1678
|
+
* wording met and its purpose not.
|
|
1679
|
+
*
|
|
1680
|
+
* 🚨 **An opt-OUT, held to `deformMayFold`'s standard.** The shape is
|
|
1681
|
+
* `{ "why": … }` and a missing, blank or non-string `why` is refused by name.
|
|
1682
|
+
* What it buys is a **SKIP, never a pass**, and the SKIP carries the cost —
|
|
1683
|
+
* the keyed bones, how many mesh attachments they drive and how many vertices
|
|
1684
|
+
* those hold. A declaration on a rig whose `idle` keys no mesh-driving bone is
|
|
1685
|
+
* refused at the gate: it would switch off nothing while reading like it did.
|
|
1686
|
+
*/
|
|
1687
|
+
idleDrivesMeshes?: RigIdleDrivesMeshes;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
/** The rig's `idle` is meant to deform meshes — `invariants.idleDrivesMeshes` (`A15`). */
|
|
1691
|
+
export interface RigIdleDrivesMeshes {
|
|
1692
|
+
/** Required, and blank is refused by name — see `idleDrivesMeshes`. */
|
|
1693
|
+
why: string;
|
|
1594
1694
|
}
|
|
1595
1695
|
|
|
1596
1696
|
/** One constraint whose mix the consumer drives — `invariants.consumerDrivenMix`. */
|
|
@@ -1696,7 +1796,8 @@ export const RIG_KEYS = {
|
|
|
1696
1796
|
RigBoneFrom: ['anchor', 'slotWindow', 'meshCenter', 'rotation'],
|
|
1697
1797
|
RigSlot: ['name', 'bone', 'attachment', 'color', 'dark', 'blend'],
|
|
1698
1798
|
RigEvent: ['int', 'float', 'string', 'audio', 'volume', 'balance'],
|
|
1699
|
-
RigInvariants: ['meshSlots', 'meshTriangles', 'axisBone', 'massBone', 'detached', 'deformMayFold', 'editorRoundTrip', 'consumerDrivenMix'],
|
|
1799
|
+
RigInvariants: ['meshSlots', 'meshTriangles', 'axisBone', 'massBone', 'detached', 'deformMayFold', 'editorRoundTrip', 'consumerDrivenMix', 'idleDrivesMeshes'],
|
|
1800
|
+
RigIdleDrivesMeshes: ['why'],
|
|
1700
1801
|
RigDetachedRule: ['bone', 'notUnder', 'why'],
|
|
1701
1802
|
RigDeformFoldExemption: ['slot', 'why'],
|
|
1702
1803
|
RigConsumerDrivenMix: ['constraint', 'type', 'why'],
|
|
@@ -1745,6 +1846,9 @@ export const RIG_KEYS = {
|
|
|
1745
1846
|
RigRibbonGenerator: ['kind', 'size', 'rows', 'chain'],
|
|
1746
1847
|
RigContourGenerator: ['kind', 'tolerance', 'margin', 'maxVertices', 'alpha', 'depth', 'soft'],
|
|
1747
1848
|
RigGridGenerator: ['kind', 'us', 'vs', 'cols', 'rows', 'depth', 'soft'],
|
|
1849
|
+
RigSegmentsGenerator: ['kind', 'cell', 'bones', 'falloff', 'alpha', 'anchor'],
|
|
1850
|
+
RigSegmentsFalloff: ['power', 'radius', 'maxBones', 'minWeight'],
|
|
1851
|
+
RigSegmentSpan: ['bone', 'from', 'to'],
|
|
1748
1852
|
RigMeshBias: ['axis_deg', 'ramp'],
|
|
1749
1853
|
RigDepthMap: ['image', 'near', 'zScale', 'gamma', 'contrast', 'bias'],
|
|
1750
1854
|
RigSoftRegion: ['bone', 'mask'],
|
|
@@ -1781,6 +1885,7 @@ const GENERATOR_SHAPE: Record<string, keyof typeof RIG_KEYS> = {
|
|
|
1781
1885
|
ribbon: 'RigRibbonGenerator',
|
|
1782
1886
|
contour: 'RigContourGenerator',
|
|
1783
1887
|
grid: 'RigGridGenerator',
|
|
1888
|
+
segments: 'RigSegmentsGenerator',
|
|
1784
1889
|
};
|
|
1785
1890
|
|
|
1786
1891
|
/**
|
|
@@ -1926,6 +2031,7 @@ function checkRigSpecKeys(raw: Record<string, unknown>, where: string): void {
|
|
|
1926
2031
|
for (const [i, rule] of (Array.isArray(raw.invariants.consumerDrivenMix) ? raw.invariants.consumerDrivenMix : []).entries()) {
|
|
1927
2032
|
at(rule, 'RigConsumerDrivenMix', `invariants.consumerDrivenMix[${i}]`);
|
|
1928
2033
|
}
|
|
2034
|
+
at(raw.invariants.idleDrivesMeshes, 'RigIdleDrivesMeshes', 'invariants.idleDrivesMeshes');
|
|
1929
2035
|
}
|
|
1930
2036
|
|
|
1931
2037
|
for (const [skinName, skin] of Object.entries(isObj(raw.skins) ? raw.skins : {})) {
|
|
@@ -1985,6 +2091,10 @@ function checkRigSpecKeys(raw: Record<string, unknown>, where: string): void {
|
|
|
1985
2091
|
at(gen.bias, 'RigMeshBias', `${who} generator.bias`);
|
|
1986
2092
|
at(gen.depth, 'RigDepthMap', `${who} generator.depth`);
|
|
1987
2093
|
at(gen.soft, 'RigSoftRegion', `${who} generator.soft`);
|
|
2094
|
+
at(gen.falloff, 'RigSegmentsFalloff', `${who} generator.falloff`);
|
|
2095
|
+
for (const [i, entry] of (Array.isArray(gen.bones) ? gen.bones : []).entries()) {
|
|
2096
|
+
at(entry, 'RigSegmentSpan', `${who} generator.bones[${i}]`);
|
|
2097
|
+
}
|
|
1988
2098
|
}
|
|
1989
2099
|
}
|
|
1990
2100
|
}
|
|
@@ -2134,9 +2244,9 @@ export function parseRigSpec(raw: unknown, where: string): RigSpec {
|
|
|
2134
2244
|
}
|
|
2135
2245
|
}
|
|
2136
2246
|
|
|
2137
|
-
// `invariants.deformMayFold` — one of the
|
|
2138
|
-
// CHECK OFF (`consumerDrivenMix`, below,
|
|
2139
|
-
// checked harder than the fields that turn one on. A
|
|
2247
|
+
// `invariants.deformMayFold` — one of the three fields in this file that TURN
|
|
2248
|
+
// A CHECK OFF (`consumerDrivenMix` and `idleDrivesMeshes`, below, are the
|
|
2249
|
+
// others), so its own shape is checked harder than the fields that turn one on. A
|
|
2140
2250
|
// typo in a slot name here would silently exempt nothing and gate everything,
|
|
2141
2251
|
// which reads exactly like the check working; and an exemption with no reason
|
|
2142
2252
|
// is unreviewable six months later. Both are refused by name.
|
|
@@ -2265,6 +2375,29 @@ export function parseRigSpec(raw: unknown, where: string): RigSpec {
|
|
|
2265
2375
|
}
|
|
2266
2376
|
}
|
|
2267
2377
|
|
|
2378
|
+
// `invariants.idleDrivesMeshes` — the third field that TURNS A CHECK OFF
|
|
2379
|
+
// (issues #855, #858), so it is held to the same standard: the one shape it
|
|
2380
|
+
// accepts is `{ "why": … }`, and a `why` that is missing, blank or not a
|
|
2381
|
+
// string is refused by name. `true` is refused too, even though it reads like
|
|
2382
|
+
// the obvious spelling — a switch with no reason attached is exactly the
|
|
2383
|
+
// exemption nobody can review later. Whether the declaration switches off
|
|
2384
|
+
// anything is a question about the emitted `idle`, so it is the gate's (A15
|
|
2385
|
+
// refuses a stale one), not this parser's.
|
|
2386
|
+
const idleDrives = spec.invariants?.idleDrivesMeshes;
|
|
2387
|
+
if (idleDrives !== undefined) {
|
|
2388
|
+
const shape = '{ "why": "<why this idle deforms meshes on purpose>" }';
|
|
2389
|
+
if (!isObj(idleDrives)) {
|
|
2390
|
+
throw new CompileError(`${where}: invariants.idleDrivesMeshes is ${JSON.stringify(idleDrives)}, expected ${shape}`);
|
|
2391
|
+
}
|
|
2392
|
+
if (typeof idleDrives.why !== 'string' || idleDrives.why.trim().length === 0) {
|
|
2393
|
+
throw new CompileError(
|
|
2394
|
+
`${where}: invariants.idleDrivesMeshes needs a "why" (a non-blank string), got ` +
|
|
2395
|
+
`${idleDrives.why === undefined ? 'none' : JSON.stringify(idleDrives.why)} — expected ${shape}. This field switches ` +
|
|
2396
|
+
'A15_IDLE_NO_MESH_BONE_KEYS off, and an exemption nobody can date or justify is how a defect ships as a decision',
|
|
2397
|
+
);
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2268
2401
|
// --- skins: the attachment table, and what the skin ACTIVATES --------------
|
|
2269
2402
|
//
|
|
2270
2403
|
// The lists resolve by name like everything else in this format, and the
|