spine-rigc 0.22.2 → 0.24.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
@@ -92,6 +92,7 @@ import {
92
92
  import { readFileSync } from 'node:fs';
93
93
  import { join } from 'node:path';
94
94
  import { Plate, readPlate, type RGBA } from '../tools/plate.ts';
95
+ import { pageFootprint } from './atlas.ts';
95
96
 
96
97
  /** Opaque, and light: both of rung 3's parts are dark slate, so is every ground. */
97
98
  export const BACKGROUND: RGBA = [232, 232, 232, 255];
@@ -184,6 +185,19 @@ export interface FramesSidecar {
184
185
  example?: string;
185
186
  rung?: string;
186
187
  skeleton?: string;
188
+ /**
189
+ * The skin these frames were posed under, when one was asked for (issue #571).
190
+ *
191
+ * ⭐ **Absent is not `"default"`.** A render with no skin sets none — every
192
+ * slot resolves through `SkeletonData.defaultSkin` alone — and a frame set
193
+ * written before this field existed says nothing either, so the two are the
194
+ * same fact on disk and the field is omitted for both. That is what keeps
195
+ * every frame set in this repository byte-identical across this change, and it
196
+ * is why `check` can refuse a mismatch it can SEE (`skin` present and
197
+ * different, or present where the run asked for none) and can only NOTE the
198
+ * one it cannot (`skin` absent while the run asked for one).
199
+ */
200
+ skin?: string;
187
201
  /** The colour the frames were cleared to, straight RGBA 0..255. */
188
202
  background: RGBA;
189
203
  viewport: {
@@ -281,6 +295,71 @@ export interface PoseOptions {
281
295
  * one instrument that does (`bonedist.ts`) needs it on every frame.
282
296
  */
283
297
  bones?: boolean;
298
+ /**
299
+ * Pose under this skin, by the name the skeleton declares for it.
300
+ *
301
+ * ⭐ Absent means **no skin is set at all**, which is spine-core's own initial
302
+ * state (`Skeleton.skin` is null) and resolves every slot through
303
+ * `SkeletonData.defaultSkin` alone. That is not the same claim as "the default
304
+ * skin was chosen": it is the absence of a choice, and the two are spelled
305
+ * differently everywhere this travels — the frames sidecar omits the field
306
+ * rather than writing `"default"` into it (issue #571).
307
+ *
308
+ * ⚠️ Read by the SAMPLERS, never by `piecesOf`, which is handed a skeleton
309
+ * somebody else already posed; handing it one is refused by name rather than
310
+ * ignored, because a skin quietly dropped here is exactly the silence this
311
+ * whole flag exists to remove.
312
+ */
313
+ skin?: string;
314
+ }
315
+
316
+ /**
317
+ * A fresh skeleton with `skin` applied, or refused by name.
318
+ *
319
+ * ## Why the skin goes on before `setupPose`, and why nothing else is needed
320
+ *
321
+ * `Skeleton.setSkin` (spine-core 4.3.13 `Skeleton.js:279-311`) attaches the new
322
+ * skin's art into each slot's pose and calls `updateCache`, which is what turns
323
+ * on a `skinRequired` bone or constraint the skin names. Every sampler below
324
+ * then calls `skeleton.setupPose()`, and `setupPose` → `setupPoseSlots`
325
+ * (`Skeleton.js:231-249`) re-resolves each slot's setup attachment through
326
+ * `Slot.setupPose` → `Skeleton.getAttachment`, which checks `this.skin` first
327
+ * and `SkeletonData.defaultSkin` second (`Skeleton.js:335-346`). So the setup
328
+ * pose of a skinned skeleton is already the skin's, and the extra
329
+ * `setSlotsToSetupPose()` a 4.1-era recipe prescribes has no 4.3 spelling to
330
+ * call: the method is named `setupPoseSlots` here, and `setupPose()` runs it.
331
+ *
332
+ * `setSkin(string)` exists too, but its by-name half throws
333
+ * `Skin not found: <name>` (`Skeleton.js:286-291`) — a message that names the
334
+ * miss and not the alternatives. Everything in a rig resolves by name and a miss
335
+ * is refused **by name, with the names that would have worked**, so the lookup
336
+ * happens here and the runtime is handed a `Skin` it cannot fail on.
337
+ */
338
+ function skeletonUnderSkin(data: SkeletonData, skin: string | undefined): Skeleton {
339
+ const skeleton = new Skeleton(data);
340
+ if (skin === undefined) return skeleton;
341
+ const found = data.findSkin(skin);
342
+ if (!found) {
343
+ throw new Error(
344
+ `no skin ${JSON.stringify(skin)} in this skeleton; it declares [${
345
+ data.skins.map((s) => s.name).join(', ') || 'none'
346
+ }]`,
347
+ );
348
+ }
349
+ skeleton.setSkin(found);
350
+ return skeleton;
351
+ }
352
+
353
+ /**
354
+ * The same options with the skin taken off — what the samplers hand `piecesOf`.
355
+ *
356
+ * Spelled once, so the one function that must not see a `skin` cannot come to
357
+ * see one because a second call site forgot.
358
+ */
359
+ function piecesOptions(opts: PoseOptions | undefined): PoseOptions | undefined {
360
+ if (opts === undefined || opts.skin === undefined) return opts;
361
+ const { skin: _applied, ...rest } = opts;
362
+ return rest;
284
363
  }
285
364
 
286
365
  /**
@@ -460,7 +539,8 @@ export function sampleAnimation(data: SkeletonData, name: string, fps: number, o
460
539
  `no animation "${name}" in this skeleton; it has [${data.animations.map((a) => a.name).join(', ') || 'none'}]`,
461
540
  );
462
541
  }
463
- const skeleton = new Skeleton(data);
542
+ const skeleton = skeletonUnderSkin(data, opts?.skin);
543
+ const pieceOpts = piecesOptions(opts);
464
544
  const state = new AnimationState(new AnimationStateData(data));
465
545
  // Not looping: the last frame sits at the animation's duration, and a looping
466
546
  // entry would wrap it back onto the first pose.
@@ -484,7 +564,7 @@ export function sampleAnimation(data: SkeletonData, name: string, fps: number, o
484
564
  frames.push({
485
565
  index: i,
486
566
  time: i * step,
487
- pieces: piecesOf(skeleton, opts),
567
+ pieces: piecesOf(skeleton, pieceOpts),
488
568
  ...(opts?.bones ? { bones: boneSnapshots(skeleton) } : {}),
489
569
  });
490
570
  }
@@ -500,7 +580,7 @@ export function sampleAnimation(data: SkeletonData, name: string, fps: number, o
500
580
  * whole content is the setup pose.
501
581
  */
502
582
  export function sampleSetupPose(data: SkeletonData, opts?: PoseOptions): Frame[] {
503
- const skeleton = new Skeleton(data);
583
+ const skeleton = skeletonUnderSkin(data, opts?.skin);
504
584
  skeleton.setupPose();
505
585
  skeleton.update(0);
506
586
  skeleton.updateWorldTransform(Physics.reset);
@@ -508,7 +588,7 @@ export function sampleSetupPose(data: SkeletonData, opts?: PoseOptions): Frame[]
508
588
  {
509
589
  index: 0,
510
590
  time: 0,
511
- pieces: piecesOf(skeleton, opts),
591
+ pieces: piecesOf(skeleton, piecesOptions(opts)),
512
592
  ...(opts?.bones ? { bones: boneSnapshots(skeleton) } : {}),
513
593
  },
514
594
  ];
@@ -519,10 +599,10 @@ export function sampleSetupPose(data: SkeletonData, opts?: PoseOptions): Frame[]
519
599
  * filed under. A skeleton with no animation at all contributes its setup pose
520
600
  * under `SETUP_POSE_DIR`.
521
601
  */
522
- export function sampleAll(data: SkeletonData, fps: number): Map<string, Frame[]> {
602
+ export function sampleAll(data: SkeletonData, fps: number, opts?: PoseOptions): Map<string, Frame[]> {
523
603
  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));
604
+ if (data.animations.length === 0) out.set(SETUP_POSE_DIR, sampleSetupPose(data, opts));
605
+ else for (const animation of data.animations) out.set(animation.name, sampleAnimation(data, animation.name, fps, opts));
526
606
  return out;
527
607
  }
528
608
 
@@ -537,6 +617,17 @@ export function sampleAll(data: SkeletonData, fps: number): Map<string, Frame[]>
537
617
  * of them draws a pixel.
538
618
  */
539
619
  export function piecesOf(skeleton: Skeleton, opts?: PoseOptions): Piece[] {
620
+ // A skin is chosen before a skeleton is posed, and this one is already posed —
621
+ // so there is nothing honest to do with the name except say so. Silently
622
+ // ignoring it is the shape of defect #571 itself: a skin asked for, no skin
623
+ // applied, and a picture that looks like an answer.
624
+ if (opts?.skin !== undefined) {
625
+ throw new Error(
626
+ `piecesOf was asked for skin ${JSON.stringify(opts.skin)}, and it reads a skeleton that is already posed. ` +
627
+ 'Ask a sampler for it — sampleSetupPose/sampleAnimation/sampleAll take { skin } — or call ' +
628
+ 'skeleton.setSkin(...) and skeleton.setupPose() before this.',
629
+ );
630
+ }
540
631
  const pieces: Piece[] = [];
541
632
  for (const slot of skeleton.drawOrder.appliedPose) {
542
633
  const pose = slot.appliedPose;
@@ -718,19 +809,20 @@ export function atlasScales(atlasText: string): number[] {
718
809
  * (The same gap is why `RegionAttachment.computeUVs` draws a 270-packed region
719
810
  * wrong, which is what `--atlas` was measuring on rung 7 — issue #199.)
720
811
  * `region.u/v` are always `x/pageWidth, y/pageHeight` and are used as they are; the
721
- * size is the region's own, transposed for a quarter turn, which is what the atlas
722
- * format means by `bounds` on a rotated region.
812
+ * size is `pageFootprint`'s, which is the region's own transposed for a quarter
813
+ * turn — what the atlas format means by `bounds` on a rotated region. That
814
+ * derivation was written out here, and in three other places that wanted the same
815
+ * rectangle; two of them had it wrong at 270 (issue #579), so it is one function
816
+ * now and this is one of its callers.
723
817
  */
724
818
  function windowOf(region: TextureAtlasRegion): UvWindow {
725
- const turned = region.degrees === 90 || region.degrees === 270;
726
- const rectWidth = turned ? region.height : region.width;
727
- const rectHeight = turned ? region.width : region.height;
819
+ const rect = pageFootprint(region);
728
820
  const page = region.page;
729
821
  return {
730
822
  u0: region.x / page.width,
731
823
  v0: region.y / page.height,
732
- u1: (region.x + rectWidth) / page.width,
733
- v1: (region.y + rectHeight) / page.height,
824
+ u1: (region.x + rect.width) / page.width,
825
+ v1: (region.y + rect.height) / page.height,
734
826
  };
735
827
  }
736
828
 
@@ -970,11 +1062,15 @@ export function trimmedUnionBounds(
970
1062
  * Measuring the box densely and once makes the framing a property of the SHOT,
971
1063
  * so every rate of one skeleton lands on the same pixels.
972
1064
  */
973
- export function framingViewport(data: SkeletonData, maxSide: number): Viewport | null {
1065
+ export function framingViewport(data: SkeletonData, maxSide: number, opts?: PoseOptions): Viewport | null {
1066
+ // The skin belongs here as much as in the frames: the union box is over the
1067
+ // attachments that POSE, and two skins fill a slot with art of different sizes
1068
+ // in different places. Framing one skin's shot with another skin's box would
1069
+ // put the difference between two skins into every measurement taken in it.
974
1070
  const sets =
975
1071
  data.animations.length === 0
976
- ? [sampleSetupPose(data)]
977
- : data.animations.map((a) => sampleAnimation(data, a.name, FRAMING_FPS));
1072
+ ? [sampleSetupPose(data, opts)]
1073
+ : data.animations.map((a) => sampleAnimation(data, a.name, FRAMING_FPS, opts));
978
1074
  const box = unionBounds(sets);
979
1075
  if (!Number.isFinite(box.minX)) return null;
980
1076
  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/timelines.ts CHANGED
@@ -9,6 +9,11 @@
9
9
  * have had to restate it — and a second copy of a catalogue is a second copy
10
10
  * that goes stale silently.
11
11
  *
12
+ * `PHYSICS_POSE_RULES` at the bottom is here for that reason and no other: the
13
+ * compiler refuses an out-of-range physics value a spec states, `A23` names one
14
+ * in a file rigc did not write, and the two have to be the same criterion rather
15
+ * than two readings of one (issue #610).
16
+ *
12
17
  * Pure JSON reading. No spine-core, no filesystem. The line numbers cited are
13
18
  * into `SkeletonJson.ts` on branch 4.3; the field-by-field survey is in
14
19
  * `docs/SPEC_COVERAGE.md` part 1-8.
@@ -255,3 +260,161 @@ export function walkTimelines(
255
260
  }
256
261
  }
257
262
 
263
+
264
+ /**
265
+ * A physics constraint's pose, as the four fields `A23` judges.
266
+ *
267
+ * Structural rather than spine-core's `PhysicsConstraintPose`, because this
268
+ * module links no runtime (see the header) — the runtime's class satisfies it,
269
+ * and so does the probe `validate.ts` hands the runtime's own timeline `set` to
270
+ * fill.
271
+ */
272
+ export interface PhysicsJudgedPose {
273
+ mix: number;
274
+ massInverse: number;
275
+ strength: number;
276
+ damping: number;
277
+ }
278
+
279
+ /**
280
+ * One physics property `A23` has an opinion about, stated once for the two
281
+ * layers that hold it.
282
+ *
283
+ * ⚠️ **The keyed number and the pose field are not always the same number.**
284
+ * `mass` is the one: `PhysicsConstraintMassTimeline.set` is
285
+ * `pose.massInverse = 1 / value` (`Animation.js:2132-2145`) and the parser does
286
+ * the same to a constraint's own `mass` (`SkeletonJson.js:309`), so a key states
287
+ * a mass and the integrator reads its reciprocal. `toPose` IS that transform, and
288
+ * every predicate here is written against the pose field rather than against the
289
+ * keyed number — which is what makes "the compiler and the assertion apply the
290
+ * same criterion" a property of the code and not a claim about it.
291
+ */
292
+ export interface PhysicsPoseRule {
293
+ /** The timeline name in skeleton JSON, and the motion spec's `property`. */
294
+ timeline: string;
295
+ /** The pose field the integrator reads. */
296
+ field: keyof PhysicsJudgedPose;
297
+ /** The pose field, from the number a key or the rig's tuning table states. */
298
+ toPose: (value: number) => number;
299
+ /** True when the integrator can use that pose field. */
300
+ poseOk: (poseValue: number) => boolean;
301
+ /**
302
+ * The same question asked of a KEY, where it differs — `null` means it does
303
+ * not. Only `mix` has one, and the runtime is the reason: `update` opens with
304
+ * `if (mix === 0) return;` (`PhysicsConstraint.js:109-111`) and
305
+ * `PhysicsConstraintPose` documents the field as "a percentage (0+)", so a
306
+ * mix of exactly 0 is a state the runtime has a branch for. A setup pose at 0
307
+ * is a constraint that does nothing unless an animation rescues it; a KEY at 0
308
+ * is an animation muting it for a stretch, which is what a mix timeline is for
309
+ * — measured, not assumed: the editor's own `sack-pro` example keys mix to 0 on
310
+ * 24 of its 36 mix keys, and applying the setup rule to keys would refuse all
311
+ * 24 (issue #610).
312
+ */
313
+ keyOk: ((poseValue: number) => boolean) | null;
314
+ /** The bound in words, for a message: what the value has to be. */
315
+ states: string;
316
+ /** The bound a KEY is held to, where `keyOk` widens it. */
317
+ statesKeyed: string;
318
+ /** What the runtime does outside the bound, with the lines that say so. */
319
+ why: string;
320
+ }
321
+
322
+ /**
323
+ * Every physics property with a bound the runtime supports, and **only** those.
324
+ *
325
+ * 🚫 `inertia`, `wind` and `gravity` are absent on purpose. The runtime
326
+ * documents no range for any of them and the integrator diverges on none:
327
+ * `inertia` scales how much bone movement is converted (`PhysicsConstraint.js:137,143`)
328
+ * so 0 is an inert frame and nothing worse, and `wind`/`gravity` are forces along
329
+ * the skeleton's own vectors (`:151-153, :214-215`) where a negative number is the
330
+ * other direction — the corpus keys `wind` at −27.4 through −12.6 on all 48 of its
331
+ * wind keys. Inventing a bound for them would refuse correct data, which is the
332
+ * failure this repository has already paid for twice (issues #44, #262).
333
+ *
334
+ * 🚫 There is no UPPER bound on `mix` either, for the same reason and a stronger
335
+ * one: `PhysicsConstraintPose` documents it as "a percentage (0+)", and a keyed
336
+ * mix of 1.5 changes nothing inside the integration at all — it multiplies the
337
+ * finished offset onto the bone (`:172,174,251,256,287`), so it is an over-mix and
338
+ * an over-mix is a real idiom (the same argument `CONSTRAINT_TIMELINES` makes for
339
+ * a transform mix).
340
+ */
341
+ export const PHYSICS_POSE_RULES: PhysicsPoseRule[] = [
342
+ {
343
+ timeline: 'mix',
344
+ field: 'mix',
345
+ toPose: (v) => v,
346
+ poseOk: (v) => v > 0,
347
+ keyOk: (v) => v >= 0,
348
+ states: '> 0',
349
+ statesKeyed: '>= 0',
350
+ why: 'the runtime documents it as a percentage (0+) and `update` returns immediately at 0 (`PhysicsConstraint.js:109-111`)',
351
+ },
352
+ {
353
+ timeline: 'mass',
354
+ field: 'massInverse',
355
+ toPose: (v) => 1 / v,
356
+ poseOk: (v) => Number.isFinite(v) && v > 0,
357
+ keyOk: null,
358
+ states: '> 0',
359
+ statesKeyed: '> 0',
360
+ why:
361
+ 'the pose holds 1/mass, so 0 is an infinite massInverse and `m = t * massInverse` ' +
362
+ '(`PhysicsConstraint.js:149,211`) takes every velocity to NaN, while a negative mass ' +
363
+ 'injects energy instead of resisting it',
364
+ },
365
+ {
366
+ timeline: 'strength',
367
+ field: 'strength',
368
+ toPose: (v) => v,
369
+ poseOk: (v) => v > 0,
370
+ keyOk: null,
371
+ states: '> 0',
372
+ statesKeyed: '> 0',
373
+ why:
374
+ 'it is the restoring force — `velocity += (a - offset * strength) * m` ' +
375
+ '(`PhysicsConstraint.js:150,156,212,220`) — so at 0 nothing pulls the offset back and it drifts',
376
+ },
377
+ {
378
+ timeline: 'damping',
379
+ field: 'damping',
380
+ toPose: (v) => v,
381
+ poseOk: (v) => v > 0 && v < 1,
382
+ keyOk: null,
383
+ states: 'inside (0, 1)',
384
+ statesKeyed: 'inside (0, 1)',
385
+ why:
386
+ 'the per-step decay is `damping ** (60 * step)` and every velocity is multiplied by it ' +
387
+ '(`PhysicsConstraint.js:148,158,163,210,222,227`), so 1 never decays, above 1 diverges, and ' +
388
+ 'at or below 0 the velocity is killed outright or raised to a fractional power',
389
+ },
390
+ ];
391
+
392
+ /** The rule for one timeline name, or `undefined` where the runtime bounds nothing. */
393
+ export function physicsRuleFor(timeline: string): PhysicsPoseRule | undefined {
394
+ return PHYSICS_POSE_RULES.find((rule) => rule.timeline === timeline);
395
+ }
396
+
397
+ /**
398
+ * Whether the number a KEY states is one the runtime can use, judged on the pose
399
+ * field it becomes rather than on itself.
400
+ *
401
+ * `null` when it is. The string is the tail of a message and names the bound and
402
+ * the reason, never just "invalid".
403
+ */
404
+ export function physicsKeyRefusal(rule: PhysicsPoseRule, value: number, posedBy?: number): string | null {
405
+ // ⚠️ `posedBy` exists so the VALIDATOR can hand over the number the runtime's
406
+ // own `PhysicsConstraint*Timeline.set` wrote, rather than rigc's reading of
407
+ // what that call does. The compiler cannot: it links no runtime, by the rule in
408
+ // CLAUDE.md, so it passes nothing and `toPose` answers. Those are two paths to
409
+ // one number and a selftest control measures that they agree — without it this
410
+ // parameter would be exactly the silent second opinion this table exists to
411
+ // remove.
412
+ const posed = posedBy ?? rule.toPose(value);
413
+ const ok = rule.keyOk ?? rule.poseOk;
414
+ if (ok(posed)) return null;
415
+ // The pose field only when it is a different number from the keyed one, which
416
+ // is derived rather than declared: it is exactly `mass`, and only where the
417
+ // reciprocal has moved.
418
+ const shown = posed === value ? '' : ` (${rule.field} ${posed})`;
419
+ return `${value}${shown}; must be ${rule.statesKeyed} — ${rule.why}`;
420
+ }
package/src/types.ts CHANGED
@@ -197,6 +197,8 @@ export interface MotionKey {
197
197
  * scale -> [x, y] as multipliers (1 = setup)
198
198
  * rotate -> [degrees]
199
199
  * mix -> [0..1] physics authority
200
+ * inertia / strength / damping / mass / wind / gravity
201
+ * -> [value]; the physics constraint's own setting, over time
200
202
  * reset -> null; the key is the event
201
203
  *
202
204
  * ⭐ On a track that names a `group`, this may instead be a **map keyed by
@@ -259,8 +261,39 @@ export type BoneProperty =
259
261
  | 'sheary'
260
262
  | 'rotate';
261
263
 
262
- /** Physics timelines the compiler emits. `reset` carries no value at all. */
263
- export type PhysicsProperty = 'mix' | 'reset';
264
+ /**
265
+ * Physics timelines the compiler emits — all eight `SkeletonJson`'s physics
266
+ * branch reads, in the order it reads them (`SkeletonJson.js:1063-1094`).
267
+ *
268
+ * Six of them are one number that overrides the constraint's own setting for
269
+ * the length of an animation: `[inertia]`, `[strength]`, `[damping]`, `[mass]`,
270
+ * `[wind]`, `[gravity]`. `mix` is the constraint's authority and `reset`
271
+ * carries no value at all.
272
+ *
273
+ * ⚠️ A key that omits its value reads **0** on all six, and 1 on `mix` — the
274
+ * per-key default, which is NOT the constraint default (`inertia` 0.5,
275
+ * `strength` 100, `damping` 0.85, `mass` 1). rigc never omits a channel, so the
276
+ * distinction only bites a reader comparing an emitted file with an editor
277
+ * export; `PHYSICS_TRACKS` in `compile.ts` carries the argument.
278
+ *
279
+ * 🔒 **Four of them have a compile-time range** (issue #610): `mass` and
280
+ * `strength` must be `> 0`, `damping` strictly inside `(0, 1)`, and `mix` `0` or
281
+ * more. The bounds are `PHYSICS_POSE_RULES` in `src/timelines.ts` and they are
282
+ * the runtime's, not a policy — `inertia`, `wind`, `gravity` and the top of
283
+ * `mix` are bounded nowhere, because the runtime documents nothing for the first
284
+ * three and documents `mix` as "a percentage (0+)". The same four rows are what
285
+ * `A23_PHYSICS_CONSTRAINT_EFFECTIVE` judges a setup pose and a foreign file's
286
+ * timeline keys with, which is why they are not stated here as numbers.
287
+ */
288
+ export type PhysicsProperty =
289
+ | 'inertia'
290
+ | 'strength'
291
+ | 'damping'
292
+ | 'mass'
293
+ | 'wind'
294
+ | 'gravity'
295
+ | 'mix'
296
+ | 'reset';
264
297
 
265
298
  /**
266
299
  * Path constraint timelines. `mix` is three values in one key —
@@ -515,13 +548,30 @@ export interface MotionTransformTrack {
515
548
  export interface MotionDeformKey {
516
549
  /** Time in seconds. */
517
550
  t: number;
518
- /** Where the run starts in the attachment's own deform array. Default 0. */
551
+ /**
552
+ * Where the run starts in the attachment's own deform array. Default 0.
553
+ *
554
+ * Any index the array holds, **odd ones included** (issue #576): the parser
555
+ * copies at this index and does no pair arithmetic, so an odd start is what an
556
+ * editor writes when it trims the leading numbers off a delta run.
557
+ */
519
558
  offset?: number;
520
559
  /** The same start, given as a vertex index. Never together with `offset`. */
521
560
  fromVertex?: number;
522
561
  /**
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.
562
+ * The run: consecutive numbers written into the deform array from `offset` on,
563
+ * `x, y` per array slot. Absent or `null` is the parser's own encoding for
564
+ * "back to the setup pose" — the key with no edit.
565
+ *
566
+ * ⚠️ **An ODD count is legal and means something** (issue #576). Both readers
567
+ * copy the run verbatim — `Utils.arrayCopy(vertices, 0, deform, offset,
568
+ * vertices.length)` in `SkeletonJson`, a `for (let v = start; v < end; v++)`
569
+ * fill in `SkeletonBinary` — so a run of three numbers moves one vertex in x
570
+ * and y and the next in x alone, leaving that y at its setup value. Padding a
571
+ * `0` to even it out is a different animation whenever that setup y is
572
+ * non-zero, which is why the even-length rule that stood here could not be kept
573
+ * as a convenience: it left one production export with no spelling in this
574
+ * spec at all.
525
575
  */
526
576
  vertices?: number[] | null;
527
577
  /**
@@ -926,10 +976,25 @@ export type SpineConstraint = { name: string; type: string } & Record<string, un
926
976
  export interface SpineSkeletonJson {
927
977
  skeleton: {
928
978
  spine: string;
929
- x: number;
930
- y: number;
931
- width: number;
932
- height: number;
979
+ /**
980
+ * The setup-pose bounding box. All four together or none of them: a rig spec
981
+ * that declares no stage (`skeleton.width`/`height` stated `null` — see
982
+ * `RigSkeletonHeader`) emits a header without any of them, which is what an
983
+ * export of a skeleton whose stage was never set carries (issue #578).
984
+ *
985
+ * ⚠️ Optional here because the *runtime* leaves them `undefined` when they
986
+ * are absent, not 0. `SkeletonData` declares `x = 0 … height = 0`
987
+ * (`SkeletonData.js:55-61`), and `SkeletonJson` then overwrites all four
988
+ * unconditionally — `skeletonData.x = skeletonMap.x` (`SkeletonJson.js:70-73`,
989
+ * no `getValue` default) — so an absent field lands as `undefined` on a
990
+ * `SkeletonData` whose own `.d.ts` types it `number`. Anything reading these
991
+ * back off a parsed skeleton guards for it; `validate.ts`'s `data.width || 0`
992
+ * is why A14 and A19 were already right about a stage-less file.
993
+ */
994
+ x?: number;
995
+ y?: number;
996
+ width?: number;
997
+ height?: number;
933
998
  fps?: number;
934
999
  referenceScale?: number;
935
1000
  images?: string;