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/README.md +80 -1
- package/cli.ts +263 -13
- package/docs/AUTHORING.md +554 -75
- package/docs/INGEST.md +238 -44
- package/docs/SPEC_COVERAGE.md +14 -3
- package/package.json +1 -1
- package/skills/ingest/SKILL.md +33 -11
- package/src/atlas.ts +135 -16
- package/src/check.ts +83 -1
- package/src/compile.ts +402 -74
- package/src/deformmeasure.ts +322 -151
- package/src/diff.ts +125 -2
- package/src/ingest.ts +1137 -0
- package/src/render.ts +113 -17
- package/src/rig.ts +92 -8
- package/src/timelines.ts +163 -0
- package/src/types.ts +74 -9
- package/src/validate.ts +765 -92
- package/tools/editor_roundtrip.ts +247 -36
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 =
|
|
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,
|
|
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 =
|
|
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
|
|
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
|
|
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 +
|
|
733
|
-
v1: (region.y +
|
|
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
|
-
|
|
91
|
-
|
|
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
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
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
|
-
/**
|
|
263
|
-
|
|
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
|
-
/**
|
|
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:
|
|
524
|
-
*
|
|
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
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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;
|