spine-rigc 0.22.2 → 0.23.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
@@ -184,6 +184,19 @@ export interface FramesSidecar {
184
184
  example?: string;
185
185
  rung?: string;
186
186
  skeleton?: string;
187
+ /**
188
+ * The skin these frames were posed under, when one was asked for (issue #571).
189
+ *
190
+ * ⭐ **Absent is not `"default"`.** A render with no skin sets none — every
191
+ * slot resolves through `SkeletonData.defaultSkin` alone — and a frame set
192
+ * written before this field existed says nothing either, so the two are the
193
+ * same fact on disk and the field is omitted for both. That is what keeps
194
+ * every frame set in this repository byte-identical across this change, and it
195
+ * is why `check` can refuse a mismatch it can SEE (`skin` present and
196
+ * different, or present where the run asked for none) and can only NOTE the
197
+ * one it cannot (`skin` absent while the run asked for one).
198
+ */
199
+ skin?: string;
187
200
  /** The colour the frames were cleared to, straight RGBA 0..255. */
188
201
  background: RGBA;
189
202
  viewport: {
@@ -281,6 +294,71 @@ export interface PoseOptions {
281
294
  * one instrument that does (`bonedist.ts`) needs it on every frame.
282
295
  */
283
296
  bones?: boolean;
297
+ /**
298
+ * Pose under this skin, by the name the skeleton declares for it.
299
+ *
300
+ * ⭐ Absent means **no skin is set at all**, which is spine-core's own initial
301
+ * state (`Skeleton.skin` is null) and resolves every slot through
302
+ * `SkeletonData.defaultSkin` alone. That is not the same claim as "the default
303
+ * skin was chosen": it is the absence of a choice, and the two are spelled
304
+ * differently everywhere this travels — the frames sidecar omits the field
305
+ * rather than writing `"default"` into it (issue #571).
306
+ *
307
+ * ⚠️ Read by the SAMPLERS, never by `piecesOf`, which is handed a skeleton
308
+ * somebody else already posed; handing it one is refused by name rather than
309
+ * ignored, because a skin quietly dropped here is exactly the silence this
310
+ * whole flag exists to remove.
311
+ */
312
+ skin?: string;
313
+ }
314
+
315
+ /**
316
+ * A fresh skeleton with `skin` applied, or refused by name.
317
+ *
318
+ * ## Why the skin goes on before `setupPose`, and why nothing else is needed
319
+ *
320
+ * `Skeleton.setSkin` (spine-core 4.3.13 `Skeleton.js:279-311`) attaches the new
321
+ * skin's art into each slot's pose and calls `updateCache`, which is what turns
322
+ * on a `skinRequired` bone or constraint the skin names. Every sampler below
323
+ * then calls `skeleton.setupPose()`, and `setupPose` → `setupPoseSlots`
324
+ * (`Skeleton.js:231-249`) re-resolves each slot's setup attachment through
325
+ * `Slot.setupPose` → `Skeleton.getAttachment`, which checks `this.skin` first
326
+ * and `SkeletonData.defaultSkin` second (`Skeleton.js:335-346`). So the setup
327
+ * pose of a skinned skeleton is already the skin's, and the extra
328
+ * `setSlotsToSetupPose()` a 4.1-era recipe prescribes has no 4.3 spelling to
329
+ * call: the method is named `setupPoseSlots` here, and `setupPose()` runs it.
330
+ *
331
+ * `setSkin(string)` exists too, but its by-name half throws
332
+ * `Skin not found: <name>` (`Skeleton.js:286-291`) — a message that names the
333
+ * miss and not the alternatives. Everything in a rig resolves by name and a miss
334
+ * is refused **by name, with the names that would have worked**, so the lookup
335
+ * happens here and the runtime is handed a `Skin` it cannot fail on.
336
+ */
337
+ function skeletonUnderSkin(data: SkeletonData, skin: string | undefined): Skeleton {
338
+ const skeleton = new Skeleton(data);
339
+ if (skin === undefined) return skeleton;
340
+ const found = data.findSkin(skin);
341
+ if (!found) {
342
+ throw new Error(
343
+ `no skin ${JSON.stringify(skin)} in this skeleton; it declares [${
344
+ data.skins.map((s) => s.name).join(', ') || 'none'
345
+ }]`,
346
+ );
347
+ }
348
+ skeleton.setSkin(found);
349
+ return skeleton;
350
+ }
351
+
352
+ /**
353
+ * The same options with the skin taken off — what the samplers hand `piecesOf`.
354
+ *
355
+ * Spelled once, so the one function that must not see a `skin` cannot come to
356
+ * see one because a second call site forgot.
357
+ */
358
+ function piecesOptions(opts: PoseOptions | undefined): PoseOptions | undefined {
359
+ if (opts === undefined || opts.skin === undefined) return opts;
360
+ const { skin: _applied, ...rest } = opts;
361
+ return rest;
284
362
  }
285
363
 
286
364
  /**
@@ -460,7 +538,8 @@ export function sampleAnimation(data: SkeletonData, name: string, fps: number, o
460
538
  `no animation "${name}" in this skeleton; it has [${data.animations.map((a) => a.name).join(', ') || 'none'}]`,
461
539
  );
462
540
  }
463
- const skeleton = new Skeleton(data);
541
+ const skeleton = skeletonUnderSkin(data, opts?.skin);
542
+ const pieceOpts = piecesOptions(opts);
464
543
  const state = new AnimationState(new AnimationStateData(data));
465
544
  // Not looping: the last frame sits at the animation's duration, and a looping
466
545
  // entry would wrap it back onto the first pose.
@@ -484,7 +563,7 @@ export function sampleAnimation(data: SkeletonData, name: string, fps: number, o
484
563
  frames.push({
485
564
  index: i,
486
565
  time: i * step,
487
- pieces: piecesOf(skeleton, opts),
566
+ pieces: piecesOf(skeleton, pieceOpts),
488
567
  ...(opts?.bones ? { bones: boneSnapshots(skeleton) } : {}),
489
568
  });
490
569
  }
@@ -500,7 +579,7 @@ export function sampleAnimation(data: SkeletonData, name: string, fps: number, o
500
579
  * whole content is the setup pose.
501
580
  */
502
581
  export function sampleSetupPose(data: SkeletonData, opts?: PoseOptions): Frame[] {
503
- const skeleton = new Skeleton(data);
582
+ const skeleton = skeletonUnderSkin(data, opts?.skin);
504
583
  skeleton.setupPose();
505
584
  skeleton.update(0);
506
585
  skeleton.updateWorldTransform(Physics.reset);
@@ -508,7 +587,7 @@ export function sampleSetupPose(data: SkeletonData, opts?: PoseOptions): Frame[]
508
587
  {
509
588
  index: 0,
510
589
  time: 0,
511
- pieces: piecesOf(skeleton, opts),
590
+ pieces: piecesOf(skeleton, piecesOptions(opts)),
512
591
  ...(opts?.bones ? { bones: boneSnapshots(skeleton) } : {}),
513
592
  },
514
593
  ];
@@ -519,10 +598,10 @@ export function sampleSetupPose(data: SkeletonData, opts?: PoseOptions): Frame[]
519
598
  * filed under. A skeleton with no animation at all contributes its setup pose
520
599
  * under `SETUP_POSE_DIR`.
521
600
  */
522
- export function sampleAll(data: SkeletonData, fps: number): Map<string, Frame[]> {
601
+ export function sampleAll(data: SkeletonData, fps: number, opts?: PoseOptions): Map<string, Frame[]> {
523
602
  const out = new Map<string, Frame[]>();
524
- if (data.animations.length === 0) out.set(SETUP_POSE_DIR, sampleSetupPose(data));
525
- else for (const animation of data.animations) out.set(animation.name, sampleAnimation(data, animation.name, fps));
603
+ if (data.animations.length === 0) out.set(SETUP_POSE_DIR, sampleSetupPose(data, opts));
604
+ else for (const animation of data.animations) out.set(animation.name, sampleAnimation(data, animation.name, fps, opts));
526
605
  return out;
527
606
  }
528
607
 
@@ -537,6 +616,17 @@ export function sampleAll(data: SkeletonData, fps: number): Map<string, Frame[]>
537
616
  * of them draws a pixel.
538
617
  */
539
618
  export function piecesOf(skeleton: Skeleton, opts?: PoseOptions): Piece[] {
619
+ // A skin is chosen before a skeleton is posed, and this one is already posed —
620
+ // so there is nothing honest to do with the name except say so. Silently
621
+ // ignoring it is the shape of defect #571 itself: a skin asked for, no skin
622
+ // applied, and a picture that looks like an answer.
623
+ if (opts?.skin !== undefined) {
624
+ throw new Error(
625
+ `piecesOf was asked for skin ${JSON.stringify(opts.skin)}, and it reads a skeleton that is already posed. ` +
626
+ 'Ask a sampler for it — sampleSetupPose/sampleAnimation/sampleAll take { skin } — or call ' +
627
+ 'skeleton.setSkin(...) and skeleton.setupPose() before this.',
628
+ );
629
+ }
540
630
  const pieces: Piece[] = [];
541
631
  for (const slot of skeleton.drawOrder.appliedPose) {
542
632
  const pose = slot.appliedPose;
@@ -970,11 +1060,15 @@ export function trimmedUnionBounds(
970
1060
  * Measuring the box densely and once makes the framing a property of the SHOT,
971
1061
  * so every rate of one skeleton lands on the same pixels.
972
1062
  */
973
- export function framingViewport(data: SkeletonData, maxSide: number): Viewport | null {
1063
+ export function framingViewport(data: SkeletonData, maxSide: number, opts?: PoseOptions): Viewport | null {
1064
+ // The skin belongs here as much as in the frames: the union box is over the
1065
+ // attachments that POSE, and two skins fill a slot with art of different sizes
1066
+ // in different places. Framing one skin's shot with another skin's box would
1067
+ // put the difference between two skins into every measurement taken in it.
974
1068
  const sets =
975
1069
  data.animations.length === 0
976
- ? [sampleSetupPose(data)]
977
- : data.animations.map((a) => sampleAnimation(data, a.name, FRAMING_FPS));
1070
+ ? [sampleSetupPose(data, opts)]
1071
+ : data.animations.map((a) => sampleAnimation(data, a.name, FRAMING_FPS, opts));
978
1072
  const box = unionBounds(sets);
979
1073
  if (!Number.isFinite(box.minX)) return null;
980
1074
  const pad = Math.max(box.maxX - box.minX, box.maxY - box.minY) * PAD;
package/src/rig.ts CHANGED
@@ -80,6 +80,25 @@ export const RIG_SPEC_VERSION = 'rigc-rig/1';
80
80
  * `A19_OVERLAY_PNGS_HAVE_ALPHA` measure against, and a guessed stage is a gate
81
81
  * that measures against a number nobody wrote down.
82
82
  *
83
+ * ⭐ **`width: null, height: null` is the third state: this skeleton declares no
84
+ * stage** (issue #578). Omitting them is silence and stays a refusal by name;
85
+ * stating them `null` is a claim, and the emitted header then carries none of
86
+ * `x`/`y`/`width`/`height` — which is what an editor export of a skeleton whose
87
+ * stage was never set looks like, and what a transcriber of one has to be able
88
+ * to write down. `null` is this spec's spelling for a stated absence everywhere
89
+ * else it has one (`RigSlot.attachment` = "show nothing", the cut manifest's
90
+ * `image` = "this cut does not carry the part"), so it is the spelling here too
91
+ * and no new key is introduced: the pair already exists, and only a third value
92
+ * of it is new.
93
+ *
94
+ * Two shapes are refused rather than interpreted, both in `parseRigSpec`:
95
+ * stating one of the pair `null` and the other a number (a stage with one
96
+ * extent is not a stage, and guessing which half was meant is inventing), and
97
+ * stating `x` or `y` alongside the absence (an origin for a box that is not
98
+ * there). ⚠️ A stated absence also beats a cut manifest's `crop`, for the reason
99
+ * a stated `width` already does: the rig spec is where a claim about the
100
+ * skeleton is made, and the manifest is a record of what the art measured.
101
+ *
83
102
  * `spine` is not here: rigc emits its own version label and `A16` re-checks it.
84
103
  * `hash` is not here either — it is the editor's change-detection token and
85
104
  * inventing one would be claiming an export this file did not come from.
@@ -87,8 +106,10 @@ export const RIG_SPEC_VERSION = 'rigc-rig/1';
87
106
  export interface RigSkeletonHeader {
88
107
  x?: number;
89
108
  y?: number;
90
- width?: number;
91
- height?: number;
109
+ /** A number, or `null` with `height` for "this skeleton declares no stage". */
110
+ width?: number | null;
111
+ /** A number, or `null` with `width` for "this skeleton declares no stage". */
112
+ height?: number | null;
92
113
  /** Nonessential; `SkeletonData.fps` stays 30 when absent. */
93
114
  fps?: number;
94
115
  /** 4.2+; the runtime's physics/scale reference. Parser default 100. */
@@ -107,6 +128,18 @@ export interface RigSkeletonHeader {
107
128
  images?: string;
108
129
  }
109
130
 
131
+ /**
132
+ * Does this header state that the skeleton has no stage?
133
+ *
134
+ * One reading of the spelling, exported so that the compiler, the emitter and
135
+ * anything that grows a third opinion later read it the same way. `parseRigSpec`
136
+ * has already refused the half-stated shapes by the time this is asked, so the
137
+ * two `null`s travel together.
138
+ */
139
+ export function declaresNoStage(header: RigSkeletonHeader | undefined): boolean {
140
+ return header !== undefined && header.width === null && header.height === null;
141
+ }
142
+
110
143
  // ---------------------------------------------------------------------------
111
144
  // bones — `root.bones[]` (SkeletonJson.ts:90-118)
112
145
  // ---------------------------------------------------------------------------
@@ -223,12 +256,22 @@ export const RIG_SLOT_BLEND: readonly RigSlotBlend[] = ['normal', 'additive', 'm
223
256
  * One slot. **The array order IS the draw order** — there is no separate setup
224
257
  * draw-order field anywhere in the format.
225
258
  *
226
- * The rig's slot list is the CANONICAL table, which is a slightly stronger claim
227
- * than "the slots this cut emits". A cut whose manifest carries no part for a
228
- * slot does not emit it, and the emitted array is then a *subsequence* of this
229
- * one; that is what `A26_SLOT_DRAW_ORDER` checks. Declaring a slot no cut fills
230
- * is therefore legitimate — it fixes where that slot will sit when a cut does
231
- * fill it.
259
+ * The rig's slot list is the CANONICAL table and **every slot in it is emitted**,
260
+ * in this order, whether or not anything fills it. A slot no skin and no manifest
261
+ * part fills is emitted with no setup attachment — the shape an editor export
262
+ * carries for a slot that shows nothing (the slot reader above takes `attachment`
263
+ * with a `null` default) — so the emitted array and this one are the same array.
264
+ * `A26_SLOT_DRAW_ORDER` checks both halves of that: nothing out of order, and
265
+ * nothing missing. Declaring a slot no cut fills is therefore legitimate, and it
266
+ * fixes where that slot sits whether or not this cut has art for it.
267
+ *
268
+ * ⚠️ Until issue #575 such a slot was **dropped**, and the gate licensed it: the
269
+ * emitted array was allowed to be any *subsequence* of this one. What that
270
+ * bought was the format's own silence. Nothing said which slot had gone, and
271
+ * every slot below it moved up one index — the index a `drawOrder` key's offsets
272
+ * are counted against, and the one an index-keyed consumer splits on. Two
273
+ * production exports declaring 53 and 61 slots built green at 51 and 57 and read
274
+ * 0.962 and 0.934 under `diff` against the file they were transcribed from.
232
275
  */
233
276
  export interface RigSlot {
234
277
  name: string;
@@ -241,6 +284,12 @@ export interface RigSlot {
241
284
  * comes from: `motion.setup` owns it, because which of the two overlay
242
285
  * mechanisms a slot uses (attachment + alpha 0, or attachment swapping) is a
243
286
  * decision about time. Declaring it in both is a compile error.
287
+ *
288
+ * Required for a slot something fills — the compiler will not guess which of
289
+ * the slot's attachments the setup pose shows — and **optional for a slot
290
+ * nothing fills**, where it can only be `null` and saying so changes no
291
+ * emitted byte. Naming an attachment on a slot nothing fills is refused: the
292
+ * name resolves to nothing, which is the shape of a half-finished wiring-up.
244
293
  */
245
294
  attachment?: string | null;
246
295
  /** `rrggbbaa`. Default opaque white. */
@@ -1520,6 +1569,15 @@ function checkRigSpecKeys(raw: Record<string, unknown>, where: string): void {
1520
1569
  const type = att.type === undefined ? 'region' : String(att.type);
1521
1570
  const shape = ATTACHMENT_SHAPE[type];
1522
1571
  if (shape === undefined) continue;
1572
+ // A mesh carrying `source` is a LINKED mesh — `type: "mesh"` and
1573
+ // `type: "linkedmesh"` share one parser branch and the `source` key is
1574
+ // what decides (`:568-569`, `:582`; SPEC_COVERAGE part 1-6). It has no
1575
+ // key set here because `RigUnimplementedAttachment` deliberately has
1576
+ // none, and checking it against a MESH's keys named the wrong fault:
1577
+ // *2 keys this compiler does not read: "source", "skin" … fix the
1578
+ // spelling or remove it*, where removing `source` is what unmakes the
1579
+ // linked mesh. `buildRigAttachment` refuses it as the construct it is.
1580
+ if (type === 'mesh' && att.source !== undefined) continue;
1523
1581
  at(att, shape, `${who} (${type})`);
1524
1582
  for (const [i, vertex] of (Array.isArray(att.weights) ? att.weights : []).entries()) {
1525
1583
  for (const [j, binding] of (Array.isArray(vertex) ? vertex : []).entries()) {
@@ -1573,6 +1631,32 @@ export function parseRigSpec(raw: unknown, where: string): RigSpec {
1573
1631
 
1574
1632
  const spec = raw as unknown as RigSpec;
1575
1633
 
1634
+ // The stage, stated or stated absent. Half a statement is refused here rather
1635
+ // than resolved in `compile`, because which half was meant is not derivable
1636
+ // and a compiler that picks one is inventing a number (issue #578).
1637
+ const header = spec.skeleton;
1638
+ if (header !== undefined) {
1639
+ const noWidth = header.width === null;
1640
+ const noHeight = header.height === null;
1641
+ if (noWidth !== noHeight) {
1642
+ const stated = noWidth ? 'height' : 'width';
1643
+ const absent = noWidth ? 'width' : 'height';
1644
+ throw new CompileError(
1645
+ `${where}: "skeleton" states ${absent}: null and a ${stated} of ` +
1646
+ `${JSON.stringify(noWidth ? header.height : header.width)}. A stage has both extents or neither: ` +
1647
+ 'write both as null for "this skeleton declares no stage", or give both a number',
1648
+ );
1649
+ }
1650
+ if (noWidth && noHeight && (header.x !== undefined || header.y !== undefined)) {
1651
+ const origin = [header.x !== undefined ? 'x' : null, header.y !== undefined ? 'y' : null].filter((k) => k !== null);
1652
+ throw new CompileError(
1653
+ `${where}: "skeleton" declares no stage (width: null, height: null) and still states ${origin.join(' and ')}. ` +
1654
+ `${origin.length === 1 ? 'That is an origin' : 'Those are an origin'} for a box that is not there: ` +
1655
+ 'drop them, or state a width and a height',
1656
+ );
1657
+ }
1658
+ }
1659
+
1576
1660
  const seen = new Set<string>();
1577
1661
  for (const bone of spec.bones) {
1578
1662
  if (!isObj(bone) || typeof bone.name !== 'string' || bone.name.length === 0) {
package/src/types.ts CHANGED
@@ -515,13 +515,30 @@ export interface MotionTransformTrack {
515
515
  export interface MotionDeformKey {
516
516
  /** Time in seconds. */
517
517
  t: number;
518
- /** Where the run starts in the attachment's own deform array. Default 0. */
518
+ /**
519
+ * Where the run starts in the attachment's own deform array. Default 0.
520
+ *
521
+ * Any index the array holds, **odd ones included** (issue #576): the parser
522
+ * copies at this index and does no pair arithmetic, so an odd start is what an
523
+ * editor writes when it trims the leading numbers off a delta run.
524
+ */
519
525
  offset?: number;
520
526
  /** The same start, given as a vertex index. Never together with `offset`. */
521
527
  fromVertex?: number;
522
528
  /**
523
- * The run: `x, y` offsets, two numbers per array slot. Absent or `null` is the
524
- * parser's own encoding for "back to the setup pose" — the key with no edit.
529
+ * The run: consecutive numbers written into the deform array from `offset` on,
530
+ * `x, y` per array slot. Absent or `null` is the parser's own encoding for
531
+ * "back to the setup pose" — the key with no edit.
532
+ *
533
+ * ⚠️ **An ODD count is legal and means something** (issue #576). Both readers
534
+ * copy the run verbatim — `Utils.arrayCopy(vertices, 0, deform, offset,
535
+ * vertices.length)` in `SkeletonJson`, a `for (let v = start; v < end; v++)`
536
+ * fill in `SkeletonBinary` — so a run of three numbers moves one vertex in x
537
+ * and y and the next in x alone, leaving that y at its setup value. Padding a
538
+ * `0` to even it out is a different animation whenever that setup y is
539
+ * non-zero, which is why the even-length rule that stood here could not be kept
540
+ * as a convenience: it left one production export with no spelling in this
541
+ * spec at all.
525
542
  */
526
543
  vertices?: number[] | null;
527
544
  /**
@@ -926,10 +943,25 @@ export type SpineConstraint = { name: string; type: string } & Record<string, un
926
943
  export interface SpineSkeletonJson {
927
944
  skeleton: {
928
945
  spine: string;
929
- x: number;
930
- y: number;
931
- width: number;
932
- height: number;
946
+ /**
947
+ * The setup-pose bounding box. All four together or none of them: a rig spec
948
+ * that declares no stage (`skeleton.width`/`height` stated `null` — see
949
+ * `RigSkeletonHeader`) emits a header without any of them, which is what an
950
+ * export of a skeleton whose stage was never set carries (issue #578).
951
+ *
952
+ * ⚠️ Optional here because the *runtime* leaves them `undefined` when they
953
+ * are absent, not 0. `SkeletonData` declares `x = 0 … height = 0`
954
+ * (`SkeletonData.js:55-61`), and `SkeletonJson` then overwrites all four
955
+ * unconditionally — `skeletonData.x = skeletonMap.x` (`SkeletonJson.js:70-73`,
956
+ * no `getValue` default) — so an absent field lands as `undefined` on a
957
+ * `SkeletonData` whose own `.d.ts` types it `number`. Anything reading these
958
+ * back off a parsed skeleton guards for it; `validate.ts`'s `data.width || 0`
959
+ * is why A14 and A19 were already right about a stage-less file.
960
+ */
961
+ x?: number;
962
+ y?: number;
963
+ width?: number;
964
+ height?: number;
933
965
  fps?: number;
934
966
  referenceScale?: number;
935
967
  images?: string;