spine-rigc 1.3.0 → 1.5.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/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 = skeletonUnderSkin(data, opts?.skin);
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 colour = pose.color;
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,369 @@ 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 pose holding a number that is not finite — refused by the bone or the vertex
1134
+ * that holds it, and the value, rather than drawn, framed or written.
1135
+ *
1136
+ * Two callers throw it, with one sentence between them (`nonFiniteSentence`):
1137
+ * the geometry export, because `JSON.stringify` writes `NaN` and `Infinity` as
1138
+ * `null` and a consumer would read a hole in the geometry as a vertex at
1139
+ * nothing; and `framingViewport`, because a box over an infinite vertex is no
1140
+ * box at all (issue #873). Before that second caller the framing answered `null`
1141
+ * for it, which `render` prints as "posed no drawable attachment" — true of a
1142
+ * skeleton that draws nothing and false of this one, whose attachment is there
1143
+ * and posed to a number no picture can hold.
1144
+ */
1145
+ export class GeometryError extends Error {}
1146
+
1147
+ /** `frames.json`'s viewport block for `v` — the one spelling both files use. */
1148
+ export function sidecarViewport(v: Viewport): FramesSidecar['viewport'] {
1149
+ return {
1150
+ x: v.minX,
1151
+ y: v.minY,
1152
+ width: v.maxX - v.minX,
1153
+ height: v.maxY - v.minY,
1154
+ scale: v.scale,
1155
+ pixelWidth: v.width,
1156
+ pixelHeight: v.height,
1157
+ };
1158
+ }
1159
+
1160
+ /**
1161
+ * The geometry file for one frame set — `frames` exactly as a sampler returned
1162
+ * them with `{ bones: true, geometry: true }`, which is what makes its grid the
1163
+ * frame set's own.
1164
+ *
1165
+ * Refused by `GeometryError`, naming the frame, the slot, the attachment and the
1166
+ * vertex (or the bone), when any number in it is not finite.
1167
+ */
1168
+ export function geometryFileOf(
1169
+ data: SkeletonData,
1170
+ animation: string | null,
1171
+ fps: number,
1172
+ frames: Frame[],
1173
+ viewport: Viewport,
1174
+ skin: string | undefined,
1175
+ ): GeometryFile {
1176
+ const geometryFrame = (frame: Frame, where: string): Omit<GeometryFrame, 'index' | 'time'> => {
1177
+ if (frame.bones === undefined || frame.attachments === undefined) {
1178
+ throw new Error(`${where} was sampled without { bones: true, geometry: true }; the geometry export needs both`);
1179
+ }
1180
+ return {
1181
+ bones: frame.bones.map(({ name, a, b, c, d, worldX, worldY }) => ({ name, a, b, c, d, worldX, worldY })),
1182
+ attachments: frame.attachments,
1183
+ };
1184
+ };
1185
+ const posed = frames.map((frame) => ({
1186
+ index: frame.index,
1187
+ time: frame.time,
1188
+ ...geometryFrame(frame, `frame ${frame.index}`),
1189
+ }));
1190
+ const setupFrame = sampleSetupPose(data, { ...(skin === undefined ? {} : { skin }), bones: true, geometry: true })[0];
1191
+ const setup = geometryFrame(setupFrame, 'the setup pose');
1192
+ const file: GeometryFile = {
1193
+ spec: GEOMETRY_SPEC,
1194
+ coordinates: GEOMETRY_COORDINATES,
1195
+ animation,
1196
+ ...(skin === undefined ? {} : { skin }),
1197
+ fps,
1198
+ viewport: sidecarViewport(viewport),
1199
+ bones: data.bones.map((bone) => ({ name: bone.name, parent: bone.parent?.name ?? null })),
1200
+ slots: data.slots.map((slot) => ({ name: slot.name, bone: slot.boneData.name })),
1201
+ rest: restOf(data, skin, [setup.attachments, ...posed.map((frame) => frame.attachments)]),
1202
+ setup,
1203
+ frames: posed,
1204
+ };
1205
+ refuseNonFinite(file);
1206
+ return file;
1207
+ }
1208
+
1209
+ /**
1210
+ * The rest table: every (slot, attachment) the given frames show, in order of
1211
+ * first appearance, posed on one setup skeleton with the slot's deform empty.
1212
+ *
1213
+ * The attachment is resolved the way the pose resolved it — `Skeleton.getAttachment`,
1214
+ * the skin first and the default skin second — so a name means the object the
1215
+ * frames drew. A region's rest corners are read for the sequence frame the setup
1216
+ * pose resolves, which is the frame its setup pose draws.
1217
+ */
1218
+ function restOf(data: SkeletonData, skin: string | undefined, shown: AttachmentPose[][]): AttachmentRest[] {
1219
+ const skeleton = setupPosed(data, skin);
1220
+ // Slot, then attachment: two maps rather than one joined key, so no pair of
1221
+ // names can fold into another's entry whatever characters they carry.
1222
+ const seen = new Map<string, Set<string>>();
1223
+ const out: AttachmentRest[] = [];
1224
+ for (const entries of shown) {
1225
+ for (const entry of entries) {
1226
+ const names = seen.get(entry.slot) ?? new Set<string>();
1227
+ if (names.has(entry.attachment)) continue;
1228
+ names.add(entry.attachment);
1229
+ seen.set(entry.slot, names);
1230
+ const slotIndex = data.findSlot(entry.slot)?.index ?? -1;
1231
+ const slot = skeleton.slots[slotIndex];
1232
+ const attachment = slot === undefined ? null : skeleton.getAttachment(slotIndex, entry.attachment);
1233
+ if (slot === undefined || !(attachment instanceof MeshAttachment || attachment instanceof RegionAttachment)) {
1234
+ throw new Error(
1235
+ `slot ${JSON.stringify(entry.slot)} showed attachment ${JSON.stringify(entry.attachment)} in a frame, and ` +
1236
+ 'the setup skeleton resolves no region or mesh of that name there',
1237
+ );
1238
+ }
1239
+ // A mesh reads the slot's deform array; the setup pose leaves it empty,
1240
+ // and emptying it here says so rather than trusting that it is.
1241
+ slot.appliedPose.deform.length = 0;
1242
+ const vertices = worldVerticesOf(skeleton, slot, attachment);
1243
+ out.push(
1244
+ attachment instanceof MeshAttachment
1245
+ ? {
1246
+ slot: entry.slot,
1247
+ attachment: entry.attachment,
1248
+ kind: 'mesh',
1249
+ vertices,
1250
+ triangles: Array.from(attachment.triangles),
1251
+ hull: attachment.hullLength / 2,
1252
+ uvs: Array.from(attachment.regionUVs),
1253
+ }
1254
+ : { slot: entry.slot, attachment: entry.attachment, kind: 'region', vertices, triangles: [...QUAD_TRIANGLES] },
1255
+ );
1256
+ }
1257
+ }
1258
+ return out;
1259
+ }
1260
+
1261
+ /** The bone fields a world transform is made of — what `firstNonFinite` reads off a bone. */
1262
+ type WorldTransform = Pick<GeometryBone, 'name' | 'a' | 'b' | 'c' | 'd' | 'worldX' | 'worldY'>;
1263
+
1264
+ /** The fields of an attachment entry `firstNonFinite` reads — a frame's `AttachmentPose` or a rest entry. */
1265
+ type PosedVertices = Pick<AttachmentPose, 'slot' | 'attachment' | 'vertices'>;
1266
+
1267
+ /**
1268
+ * The sentence for the first number at `where` that is not finite — bones before
1269
+ * vertices, since a bone that overflowed is the cause and its vertices the
1270
+ * symptom — or `null` when every number there is finite.
1271
+ */
1272
+ function firstNonFinite(where: string, entries: readonly PosedVertices[], bones: readonly WorldTransform[]): string | null {
1273
+ for (const bone of bones) {
1274
+ for (const field of ['a', 'b', 'c', 'd', 'worldX', 'worldY'] as const) {
1275
+ if (!Number.isFinite(bone[field])) {
1276
+ return `${where}: bone ${JSON.stringify(bone.name)} has ${field} ${String(bone[field])}; a world transform is finite`;
1277
+ }
1278
+ }
1279
+ }
1280
+ for (const entry of entries) {
1281
+ const bad = entry.vertices.findIndex((value) => !Number.isFinite(value));
1282
+ if (bad === -1) continue;
1283
+ return (
1284
+ `${where}: slot ${JSON.stringify(entry.slot)} attachment ${JSON.stringify(entry.attachment)} vertex ` +
1285
+ `${Math.floor(bad / 2)} has ${bad % 2 === 0 ? 'x' : 'y'} ${String(entry.vertices[bad])}; a posed vertex is finite`
1286
+ );
1287
+ }
1288
+ return null;
1289
+ }
1290
+
1291
+ /**
1292
+ * How a sampled frame is named in that sentence: the animation, the frame's
1293
+ * index at the rate it was sampled and its time — or the bare index for the one
1294
+ * frame of a skeleton with no animation, whose setup pose is checked first.
1295
+ */
1296
+ function frameWhere(animation: string | null, index: number, time: number, fps: number): string {
1297
+ if (animation === null) return `frame ${index}`;
1298
+ return `animation ${JSON.stringify(animation)} frame ${index} at ${fps} fps (t=${time.toFixed(4)}s)`;
1299
+ }
1300
+
1301
+ /** One sampled frame's numbers, under the name the sentence gives it. */
1302
+ interface NamedPose {
1303
+ where: string;
1304
+ attachments: readonly PosedVertices[];
1305
+ bones: readonly WorldTransform[];
1306
+ }
1307
+
1308
+ /**
1309
+ * ⭐ **The one derivation of the non-finite sentence** (issue #873): the setup
1310
+ * pose, then every frame in order, then the rest table. The export and the
1311
+ * framing both reach it, so `render` and `render --geometry` refuse one planted
1312
+ * overflow in the same words.
1313
+ *
1314
+ * The setup pose first because a setup bone that overflowed is named there as
1315
+ * the BONE, and the rest table — the setup's bones with no deform — would name
1316
+ * the same fault one step on, as a vertex. Rest is still checked, last: it holds
1317
+ * attachments the setup pose does not show.
1318
+ */
1319
+ function nonFiniteSentence(
1320
+ setup: Pick<NamedPose, 'attachments' | 'bones'>,
1321
+ frames: readonly NamedPose[],
1322
+ rest: readonly PosedVertices[],
1323
+ ): string | null {
1324
+ const first = firstNonFinite('the setup pose', setup.attachments, setup.bones);
1325
+ if (first !== null) return first;
1326
+ for (const frame of frames) {
1327
+ const found = firstNonFinite(frame.where, frame.attachments, frame.bones);
1328
+ if (found !== null) return found;
1329
+ }
1330
+ return firstNonFinite('the rest table', rest, []);
1331
+ }
1332
+
1333
+ /** Throw a `GeometryError` at the first number in `file` that is not finite, naming where it sits. */
1334
+ function refuseNonFinite(file: GeometryFile): void {
1335
+ const sentence = nonFiniteSentence(
1336
+ file.setup,
1337
+ file.frames.map((frame) => ({ ...frame, where: frameWhere(file.animation, frame.index, frame.time, file.fps) })),
1338
+ file.rest,
1339
+ );
1340
+ if (sentence !== null) throw new GeometryError(sentence);
1341
+ }
1342
+
1343
+ /**
1344
+ * Where a skeleton's pose is not finite, as the sentence the geometry export
1345
+ * refuses on — or `null` when every bone and vertex of it is finite.
1346
+ *
1347
+ * Each set is sampled at its own rate, with its bones and whole attachments —
1348
+ * `animation: null` is the setup pose alone — so the frame it names is a frame of
1349
+ * the caller's own grid. It samples again rather than taking the caller's
1350
+ * frames, because a caller that only draws sampled no bones; it is run only
1351
+ * once the caller has found a number that is not finite, so a finite pose never
1352
+ * pays for it.
1353
+ */
1354
+ export function nonFinitePoseOf(
1355
+ data: SkeletonData,
1356
+ skin: string | undefined,
1357
+ sets: ReadonlyArray<{ animation: string | null; fps: number }>,
1358
+ ): string | null {
1359
+ const opts: PoseOptions = { ...(skin === undefined ? {} : { skin }), bones: true, geometry: true };
1360
+ // Every attachment list the frames showed, for the rest table's roster.
1361
+ const shown: AttachmentPose[][] = [];
1362
+ const named = (frame: Frame, where: string): NamedPose => {
1363
+ if (frame.bones === undefined || frame.attachments === undefined) {
1364
+ throw new Error(`${where} was sampled without { bones: true, geometry: true }`);
1365
+ }
1366
+ shown.push(frame.attachments);
1367
+ return { where, attachments: frame.attachments, bones: frame.bones };
1368
+ };
1369
+ const setup = named(sampleSetupPose(data, opts)[0], 'the setup pose');
1370
+ const frames = sets.flatMap(({ animation, fps }) =>
1371
+ animation === null
1372
+ ? []
1373
+ : sampleAnimation(data, animation, fps, opts).map((frame) =>
1374
+ named(frame, frameWhere(animation, frame.index, frame.time, fps)),
1375
+ ),
1376
+ );
1377
+ // Not read unless the setup pose and every frame are finite: `restOf` poses
1378
+ // what they show, and would be posing the same overflow a third time.
1379
+ const found = nonFiniteSentence(setup, frames, []);
1380
+ if (found !== null) return found;
1381
+ return nonFiniteSentence({ attachments: [], bones: [] }, [], restOf(data, skin, shown));
1382
+ }
1383
+
1384
+ /**
1385
+ * The file's text: a JSON object whose header fields sit one per line and whose
1386
+ * `rest` entries and `frames` sit one per line each, so a diff of two exports
1387
+ * names the frame that moved.
1388
+ *
1389
+ * Numbers are `JSON.stringify`'s, which is how every JSON this tree writes prints
1390
+ * them — the shortest decimal that reads back as the same double, fixed by the
1391
+ * language rather than a locale — so a vertex in the file IS the runtime's
1392
+ * vertex, not a rounding of it.
1393
+ */
1394
+ export function geometryText(file: GeometryFile): string {
1395
+ const lines: string[] = [];
1396
+ const entries = Object.entries(file);
1397
+ entries.forEach(([key, value], i) => {
1398
+ const comma = i + 1 < entries.length ? ',' : '';
1399
+ if ((key === 'rest' || key === 'frames') && Array.isArray(value)) {
1400
+ if (value.length === 0) {
1401
+ lines.push(` ${JSON.stringify(key)}: []${comma}`);
1402
+ return;
1403
+ }
1404
+ lines.push(` ${JSON.stringify(key)}: [`);
1405
+ value.forEach((item, j) => lines.push(` ${JSON.stringify(item)}${j + 1 < value.length ? ',' : ''}`));
1406
+ lines.push(` ]${comma}`);
1407
+ return;
1408
+ }
1409
+ lines.push(` ${JSON.stringify(key)}: ${JSON.stringify(value)}${comma}`);
1410
+ });
1411
+ return `{\n${lines.join('\n')}\n}\n`;
1412
+ }
1413
+
954
1414
  // ---------------------------------------------------------------------------
955
1415
  // texture-only substitution — see `substituteTexture`
956
1416
  // ---------------------------------------------------------------------------
@@ -1332,6 +1792,10 @@ export function trimmedUnionBounds(
1332
1792
  *
1333
1793
  * Measuring the box densely and once makes the framing a property of the SHOT,
1334
1794
  * so every rate of one skeleton lands on the same pixels.
1795
+ *
1796
+ * `null` means the skeleton posed no vertex at all — nothing to draw. A pose
1797
+ * holding a vertex at Infinity or NaN is refused by a `GeometryError` naming the
1798
+ * bone or vertex and its value (issue #873), never answered `null`.
1335
1799
  */
1336
1800
  export function framingViewport(data: SkeletonData, maxSide: number, opts?: PoseOptions): Viewport | null {
1337
1801
  // The skin belongs here as much as in the frames: the union box is over the
@@ -1347,14 +1811,43 @@ export function framingViewport(data: SkeletonData, maxSide: number, opts?: Pose
1347
1811
  // A clip is taken off for the same reason (issue #844): what it removes still
1348
1812
  // counts toward the box, so adding or keying a mask moves no pixel it leaves
1349
1813
  // drawn — see `PoseOptions.unclipped`.
1350
- const { slots: _drawn, hidden: _hidden, ...whole } = opts ?? {};
1814
+ // Neither the bone snapshots nor the geometry export frame anything, and
1815
+ // both would be taken at `FRAMING_FPS` for every animation only to be dropped.
1816
+ const { slots: _drawn, hidden: _hidden, bones: _bones, geometry: _geometry, ...whole } = opts ?? {};
1351
1817
  const framed: PoseOptions = { ...whole, unclipped: true };
1352
1818
  const sets =
1353
1819
  data.animations.length === 0
1354
1820
  ? [sampleSetupPose(data, framed)]
1355
1821
  : data.animations.map((a) => sampleAnimation(data, a.name, FRAMING_FPS, framed));
1822
+ // ⚠️ Two different reasons a box is not finite, told apart here and nowhere
1823
+ // else (issue #873). A skeleton that posed no vertex at all has nothing to
1824
+ // draw, and that is `null`. One that posed a vertex at Infinity or NaN has a
1825
+ // drawable attachment in a place no box can hold — so it is refused by the
1826
+ // bone or vertex and its value, in the geometry export's own sentence. Before
1827
+ // this, the first case's `null` covered both, and a single overflowing bone
1828
+ // among finite ones reached neither: its box was finite on one side, and
1829
+ // `render` wrote a NaN-by-NaN frame set with exit 0.
1830
+ const posedAny = sets.some((frames) => frames.some((frame) => frame.pieces.some((piece) => piece.world.length > 0)));
1831
+ if (!posedAny) return null;
1356
1832
  const box = unionBounds(sets);
1357
- if (!Number.isFinite(box.minX)) return null;
1833
+ if (![box.minX, box.minY, box.maxX, box.maxY].every(Number.isFinite)) {
1834
+ const found = nonFinitePoseOf(
1835
+ data,
1836
+ framed.skin,
1837
+ data.animations.length === 0
1838
+ ? [{ animation: null, fps: FRAMING_FPS }]
1839
+ : data.animations.map((a) => ({ animation: a.name, fps: FRAMING_FPS })),
1840
+ );
1841
+ // Reaching here with nothing found would mean the pieces and the whole
1842
+ // attachments disagree about one pose — a defect here, said as one.
1843
+ if (found === null) {
1844
+ throw new Error(
1845
+ `the framing box is not finite (${box.minX}, ${box.minY}, ${box.maxX}, ${box.maxY}) and no bone or ` +
1846
+ 'vertex of the pose is — the drawn pieces and the attachments were posed differently',
1847
+ );
1848
+ }
1849
+ throw new GeometryError(found);
1850
+ }
1358
1851
  const pad = Math.max(box.maxX - box.minX, box.maxY - box.minY) * PAD;
1359
1852
  return viewportFor(box.minX - pad, box.minY - pad, box.maxX + pad, box.maxY + pad, maxSide);
1360
1853
  }