rig-play 1.0.0 → 1.2.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 CHANGED
@@ -245,6 +245,53 @@ frame takes the one-`clip-path`-on-the-root path), and a mesh's `uvs` and
245
245
  `triangles` are the same arrays from frame to frame for the same texture
246
246
  mapping (a new array re-rasters the mesh).
247
247
 
248
+ ### Reading the pose
249
+
250
+ Two reads off the pose the player holds — the last `frame`/`advance`/`seek`,
251
+ or the setup pose before `play` — for framing a stage around a rig:
252
+
253
+ ```ts
254
+ const head = player.bone('head'); // { x, y, a, b, c, d, rotationX, rotationY, scaleX, scaleY, active }
255
+ const box = player.bounds(); // { x, y, width, height } | null — world units, Y up
256
+ ```
257
+
258
+ `bone` is the core's world transform for that bone (`rig-c/core`'s `RawBone`,
259
+ unchanged), and an unknown name is refused by name. `bounds` is the box of
260
+ every region's four corners and every mesh's vertices in the frame record the
261
+ renderer takes; a slot under a clip counts only inside the clip polygon's own
262
+ box, an inverse clip removes nothing, and bounding boxes, paths and points do
263
+ not draw and do not count. Both cost nothing until called and read the same
264
+ numbers in `dom` and `canvas` mode.
265
+
266
+ ### Adjusting bones
267
+
268
+ A per-pose write to bone locals — a breath's scale, a look's rotation, a mouth
269
+ driven while the body holds still:
270
+
271
+ ```ts
272
+ const player = new RigPlayer(root, assets, {
273
+ adjust: (bones) => { bones.bone('mouth').scaleY = 1 + openness; },
274
+ });
275
+ player.adjust = null; // or set it later; setting it re-poses the held pose at once
276
+ ```
277
+
278
+ The function runs once per pose, after the animation's timelines and before the
279
+ world transforms, constraints and physics — `rig-c/core`'s `openTrack({ adjust })`,
280
+ which rig-c holds to the Spine runtime at tolerance 0. **A write lasts one
281
+ pose.** The core poses every step from the setup pose, so a value you want held
282
+ is written again each pose and nothing compounds; a 0.8 caller that multiplied a
283
+ channel on a skeleton it kept between frames writes the value it wants, every
284
+ pose. It runs on every pose the animation is applied to, the first one after
285
+ `play` included, and `seek` and a `mode` change keep it.
286
+
287
+ A paused player and a zero step re-pose nothing, so a change the function would
288
+ make is not seen until the next step — unless you ask: setting `player.adjust`
289
+ re-poses the held pose at the same track time, and `player.repose()` does the
290
+ same for a function whose own state changed (the mouth above), without stepping,
291
+ firing events or drawing. A non-finite value, a bone the document does not have,
292
+ or an error the function throws refuses that step in the core's words, and the
293
+ track is reopened by the next `play` or `seek`.
294
+
248
295
  ### One load, several players
249
296
 
250
297
  One `loadModel` backs any number of players: the regions are cut once, and
@@ -493,6 +540,146 @@ on-device (Chromium, dpr 2, stage under `scale(0.25)`, ~2.2 M CSS px on screen):
493
540
  | 92 meshes | `devicePixelRatio` (2) | 141.1 Mpx | 36–39 |
494
541
  | 92 meshes | `dpr × zoom` (0.5) | 9.1 Mpx | 60–61 |
495
542
 
543
+ ## Coming from spine-html 0.8
544
+
545
+ This is not a drop-in upgrade. 0.8 drew a Spine export (`.json` or `.skel`, plus its
546
+ `.atlas`) that `@esotericsoftware/spine-core` parsed and posed: the caller built the
547
+ `Skeleton` and the `AnimationState`, advanced them each frame, and handed the skeleton to
548
+ `renderer.render(skeleton)`. 1.0 loads one file, the `skeleton.model.json` that
549
+ `rigc build` writes, poses it through `rig-c/core` (bundled into the package, with no peer
550
+ dependency), and `RigPlayer` owns the track, so the caller's frame is `player.frame(dt)`.
551
+ A Spine export is refused by name before anything is fetched: the data has to be a rigc
552
+ build, and this package declares no loader for anything else.
553
+
554
+ What did not move: a positioned root element is still the rig's origin (Y up) with its
555
+ layout and scaling left to the caller, the DOM tier keeps its knobs under a new class
556
+ name, and unloading is still elements first, bitmaps second.
557
+
558
+ ### 0.8 → 1.0
559
+
560
+ | 0.8.0 (`spine-html`) | 1.0.0 (`rig-play`) | Note |
561
+ | --- | --- | --- |
562
+ | `npm i spine-html @esotericsoftware/spine-core` | `npm i rig-play`, and `npm i -D rig-c` for the build tool | 0.8 declared spine-core as a peer dependency (`>=4.0.0 <4.4.0`). 1.0.0 declares no dependencies and no peer dependencies. |
563
+ | `loadSkeletonAssets(options)` | `loadModel(options)` | One URL instead of two. The `.atlas` and `skeleton.json` a build writes beside the document are not fetched. |
564
+ | `LoadSkeletonAssetsOptions.atlasUrl`, `skeletonUrl` | `LoadModelOptions.modelUrl` | |
565
+ | `resolvePage(pageName, atlasUrl)` | `resolvePage(pageName, modelUrl)` | The default now resolves a page name against the model URL's directory. |
566
+ | `crossOrigin`, `fetch` | `crossOrigin`, `fetch` | Unchanged. |
567
+ | `scale` (`LoadSkeletonAssetsOptions`, `LoadSkeletonJsonOptions`) | none | See below. |
568
+ | `SkeletonAssets` | `ModelAssets` | |
569
+ | `SkeletonAssets.data` | `ModelAssets.document`, `animations`, `skins`, `stage`, `spec` | `document` is opaque (`ModelDocument`); the player poses it. What a caller used to read off the skeleton data is stated as `animations` (name and duration), `skins` (names) and `stage` (the stage the build declared, or null). |
570
+ | `SkeletonAssets.atlas` | `ModelAssets.pageImages` | The page images, keyed by page name. There is no atlas object; where each region sits is in the document's `pages` section. |
571
+ | `SkeletonAssets.regionImages` | `ModelAssets.regionImages` | Same `Map<string, RegionImage>`. |
572
+ | `SkeletonAssets.dispose()`, `AtlasAssets.dispose()` | `ModelAssets.dispose()` | Same contract: frees the blob URLs the load minted, idempotent, called after the players are disposed. |
573
+ | `loadAtlasAssets` + `loadSkeletonJson` (one atlas, several skeletons) | none | One `loadModel` backs any number of `RigPlayer`s of the same rig. Two rigs are two loads, and each cuts its own regions. See below. |
574
+ | `loadSkeletonBinary` from `spine-html/binary` | none | 1.0.0 has one entry point. A `.skel` URL is refused by name. |
575
+ | `unpackRegions(atlas, pageImages)` | `unpackRegions(pages, pageImages)` | The first argument is the document's `pages` section (`readonly PageLayout[]`) instead of a `TextureAtlas`. |
576
+ | `revokeRegions(images)` | `revokeRegions(images)` | Unchanged. |
577
+ | `RegionImage` | `RegionImage` | Unchanged (`url`, `width`, `height`, in atlas units). |
578
+ | `DomTexture` | none | Not exported; there is no atlas page to attach a texture to. |
579
+ | `new Skeleton(assets.data)` and `new AnimationState(new AnimationStateData(assets.data))` | `new RigPlayer(root, assets, options?)` | The player holds the pose and the track. |
580
+ | `state.setAnimation(0, 'walk', true)` | `player.play('walk', { loop: true })` | `loop` defaults to true. `play` opens the animation at its first pose with the physics reset there. `player.current` names what is playing. |
581
+ | `state.update(delta)`, `state.apply(skeleton)`, `skeleton.update(delta)`, `skeleton.updateWorldTransform(Physics.update)`, `renderer.render(skeleton)` | `player.frame(dt)` | Or `player.advance(dt)` then `player.draw()`, to time the two apart. |
582
+ | time scale (the caller's own, on the spine-core side) | `player.timeScale` | Multiplies every step. 0 holds the pose without re-posing it; a negative value is not stepped. |
583
+ | holding a pose (the caller stopped advancing) | `player.pause()`, `player.resume()` | `frame` keeps drawing the held pose. |
584
+ | moving to a time (the caller's own, on the spine-core side) | `player.seek(time)`, `RigPlayerOptions.seekStep`, `player.time` | A seek reopens the animation and steps forward in `seekStep` increments (1/60 s by default). Events and completes crossed on the way are not fired. |
585
+ | events (the caller's own listener on the spine-core side) | `player.on('event', listener)`, `player.on('complete', listener)` | `'event'` hands a `RigEvent` (`name`, `time`, `int`, `float`, `string`); `'complete'` hands `{ animation }`. `on` returns a function that removes the listener. |
586
+ | skin (set by the caller on its `Skeleton`) | `RigPlayerOptions.skin` | A constructor option. The names are in `ModelAssets.skins`. Without it the rig is posed with no skin set. |
587
+ | `SpineHtmlRenderer` | `DomRenderer`, reached as `player.renderer` | In `mode: 'dom'`, the default. `mode: 'canvas'` is new and makes `player.renderer` a `CanvasRenderer`. |
588
+ | `new SpineHtmlRenderer(root, regionImages)` | `new DomRenderer(root, regionImages, pages?)` | Only for drawing your own frames; `RigPlayer` constructs its renderer. `pages` is the page-image map the mesh tier samples (`ModelAssets.pageImages`). A hand-built `regionImages` map is still accepted. |
589
+ | `renderer.render(skeleton)` | `renderer.render(frame)` | Takes a `PosedFrame`, not a skeleton. `player.posedFrame()` returns the one the player would draw. |
590
+ | `renderer.pixelRatio` | `player.renderer.pixelRatio` | On both renderers. Carried over when `player.mode` changes. |
591
+ | `renderer.syncPixelRatio()` | `DomRenderer.syncPixelRatio()` | DOM mode only. `player.renderer` is typed as either renderer, so narrow it (`instanceof DomRenderer`) before calling. |
592
+ | `renderer.meshBackingPixels` | `DomRenderer.meshBackingPixels` | DOM mode only. |
593
+ | `renderer.meshBackend`, `meshBackendActive`, type `MeshBackend` | `DomRenderer.meshBackend`, `meshBackendActive`, type `MeshBackend` | Same values (`'canvas2d'` default, `'webgl'`). Also settable up front as `RigPlayerOptions.meshBackend`. |
594
+ | `renderer.triangleExpand` | `DomRenderer.triangleExpand` | Same default (0.5). |
595
+ | `renderer.clipping` | `player.renderer.clipping` | On both renderers. Carried over when `player.mode` changes. |
596
+ | `clipCount`, `clipSkipCount`, `clipWriteCount` | same names on `DomRenderer` | `clipSkipCount` counts less than it did: a second clip met while one is in force, and a clip on an inactive bone, no longer reach the frame, so they are not counted. |
597
+ | `meshCount`, `meshReuseCount`, `canvasReallocCount`, `triangleCount` | same names on `DomRenderer` | |
598
+ | tint: skeleton × slot × attachment colour | `player.color` × slot × attachment | `player.color` is `[r, g, b, a]` and takes the place of the skeleton's colour. The product arrives as `tint` on each `PosedSlot`. |
599
+ | dark (two-colour) tint: unsupported | `DomRenderer`: unsupported. `CanvasRenderer`: drawn | Carried as `dark` on a `PosedSlot`. The canvas mode's canvas2d fallback draws alpha only. |
600
+ | `renderer.dispose()` | `player.dispose()` | Then `assets.dispose()`, as before. `DomRenderer.dispose()` and `CanvasRenderer.dispose()` exist for a renderer you constructed yourself. |
601
+ | `skeleton.findBone('head').worldX`, `.worldY`, `.a` … `.d` | `player.bone('head')` | `{ x, y, a, b, c, d, rotationX, rotationY, scaleX, scaleY, active }`, the core's world transform for the pose the player holds (the setup pose before `play`). An unknown name is refused by name. Since 1.1.0. |
602
+ | `bone.scaleY = …` on a `Skeleton` between `state.apply` and `updateWorldTransform` | `RigPlayerOptions.adjust`, `player.adjust`, `player.repose()` | A function called once per pose with the pose's bone locals (`bones.bone('mouth').scaleY = v`). A write lasts one pose: the core poses every step from the setup pose, so write the value you want, every pose — nothing compounds. See *Adjusting bones*. Since 1.2.0. |
603
+ | `skeleton.getBounds(offset, size)` | `player.bounds()` | `{ x, y, width, height }` or null, world units, Y up: the box of every drawn region's corners and mesh's vertices, a slot under a clip counted inside the clip's own box. `ModelAssets.stage` remains the declared framing, not this box. Since 1.1.0. |
604
+
605
+ ### No counterpart in 1.0.0
606
+
607
+ Things a 0.8 caller could do because it held the spine-core objects, or through a 0.8
608
+ export, for which 1.0.0 declares nothing:
609
+
610
+ - **A Spine export as input.** `loadSkeletonJson`, `loadSkeletonBinary`, `loadAtlasAssets`,
611
+ the `spine-html/binary` entry and `DomTexture` have no counterpart, and neither does
612
+ choosing which spine-core generation reads the data. `loadModel` reads a compiled model
613
+ document and nothing else.
614
+ - **Scaling the skeleton as it is read.** `LoadModelOptions` declares no `scale`. Scaling
615
+ the root element is still the caller's.
616
+ - **One unpacked atlas shared by different skeletons.** A document is one rig, and each
617
+ `loadModel` cuts its own regions and owns its own blob URLs. Sharing is per rig: one
618
+ load, several players.
619
+ - **More than one track, mixing between animations, or queueing one after another.**
620
+ `RigPlayer` declares one track; `play` reopens it at the animation's first pose.
621
+ - **Changing the skin after construction.** `skin` is a `RigPlayerOptions` field and
622
+ `RigPlayer` declares no way to change it later; a second player over the same assets
623
+ is the declared route.
624
+ - **Changing one slot from code** (its attachment, its colour). `RigPlayer` declares only
625
+ the whole-rig `color`. The frame record is a plain object, so a caller that draws its
626
+ own frames through `DomRenderer.render` or `CanvasRenderer.render` decides what is in
627
+ it.
628
+ - **Choosing how physics is stepped each frame.** 0.8's loop passed `Physics.update`
629
+ itself; 1.0.0 declares no physics option. `play` and `seek` reset physics at the
630
+ animation's first pose and every step advances it.
631
+ - **Track callbacks other than the two declared.** `on` accepts `'event'` and
632
+ `'complete'`.
633
+
634
+ Knobs on `DomRenderer` that `CanvasRenderer` does not declare:
635
+
636
+ - `syncPixelRatio()`. `CanvasRenderer.pixelRatio` exists and is set by hand
637
+ (`devicePixelRatio * rootScale`).
638
+ - `meshBackend` and `meshBackendActive`. The canvas mode has its own pair, `backend` and
639
+ `backendActive` (type `CanvasBackend`, `'webgl'` by default).
640
+ - `triangleExpand`, `meshBackingPixels`, `clipWriteCount`, `meshCount`, `meshReuseCount`,
641
+ `triangleCount`. The canvas mode's counters are `drawCalls`, `blendFlushes`,
642
+ `trianglesDrawn`, `framesReused` and `blits`.
643
+
644
+ Both declare `pixelRatio`, `clipping`, `clipCount`, `clipSkipCount`, `canvasReallocCount`,
645
+ `render` and `dispose`. Only `pixelRatio` and `clipping` carry over when `player.mode`
646
+ changes.
647
+
648
+ ### Before and after
649
+
650
+ 0.8.0:
651
+
652
+ ```ts
653
+ import { Skeleton, AnimationState, AnimationStateData, Physics } from '@esotericsoftware/spine-core';
654
+ import { loadSkeletonAssets, SpineHtmlRenderer } from 'spine-html';
655
+ const assets = await loadSkeletonAssets({ atlasUrl: '/hero/hero.atlas', skeletonUrl: '/hero/hero.json' });
656
+ const skeleton = new Skeleton(assets.data);
657
+ const state = new AnimationState(new AnimationStateData(assets.data));
658
+ state.setAnimation(0, 'walk', true);
659
+ const renderer = new SpineHtmlRenderer(rootElement, assets.regionImages);
660
+ function frame(delta: number) {
661
+ state.update(delta);
662
+ state.apply(skeleton);
663
+ skeleton.update(delta);
664
+ skeleton.updateWorldTransform(Physics.update);
665
+ renderer.render(skeleton);
666
+ }
667
+ renderer.dispose(); assets.dispose(); // on unload
668
+ ```
669
+
670
+ 1.0.0:
671
+
672
+ ```ts
673
+ import { loadModel, RigPlayer } from 'rig-play';
674
+ const assets = await loadModel({ modelUrl: '/rigs/walk/skeleton.model.json' });
675
+ const player = new RigPlayer(rootElement, assets);
676
+ player.play('walk', { loop: true });
677
+ function frame(delta: number) {
678
+ player.frame(delta);
679
+ }
680
+ player.dispose(); assets.dispose(); // on unload
681
+ ```
682
+
496
683
  ## Demo (this repository)
497
684
 
498
685
  ```bash
package/dist/index.d.ts CHANGED
@@ -2,6 +2,6 @@ export { type CanvasBackend, CanvasRenderer, type CanvasViewport } from './Canva
2
2
  export { DomRenderer, type MeshBackend } from './DomRenderer.js';
3
3
  export { type PageLayout, type PageRegion, type RegionImage, revokeRegions, unpackRegions } from './DomTexture.js';
4
4
  export { type LoadModelOptions, loadModel, type ModelAssets, type ModelDocument } from './loadModel.js';
5
- export { type RenderMode, type RigEvent, RigPlayer, type RigPlayerOptions } from './player.js';
5
+ export { type AdjustableBones, type BoneAdjust, type BoneLocals, type BoneWorld, type PoseBounds, type RenderMode, type RigEvent, RigPlayer, type RigPlayerOptions } from './player.js';
6
6
  export type { BlendMode, PosedClip, PosedFrame, PosedMesh, PosedRegion, PosedSlot, } from './posedFrame.js';
7
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,aAAa,EAAE,cAAc,EAAE,KAAK,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAC9F,OAAO,EAAE,WAAW,EAAE,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACjE,OAAO,EAAE,KAAK,UAAU,EAAE,KAAK,UAAU,EAAE,KAAK,WAAW,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACnH,OAAO,EAAE,KAAK,gBAAgB,EAAE,SAAS,EAAE,KAAK,WAAW,EAAE,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAC;AACxG,OAAO,EAAE,KAAK,UAAU,EAAE,KAAK,QAAQ,EAAE,SAAS,EAAE,KAAK,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/F,YAAY,EACV,SAAS,EACT,SAAS,EACT,UAAU,EACV,SAAS,EACT,WAAW,EACX,SAAS,GACV,MAAM,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,aAAa,EAAE,cAAc,EAAE,KAAK,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAC9F,OAAO,EAAE,WAAW,EAAE,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACjE,OAAO,EAAE,KAAK,UAAU,EAAE,KAAK,UAAU,EAAE,KAAK,WAAW,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACnH,OAAO,EAAE,KAAK,gBAAgB,EAAE,SAAS,EAAE,KAAK,WAAW,EAAE,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAC;AACxG,OAAO,EAAE,KAAK,eAAe,EAAE,KAAK,UAAU,EAAE,KAAK,UAAU,EAAE,KAAK,SAAS,EAAE,KAAK,UAAU,EAAE,KAAK,UAAU,EAAE,KAAK,QAAQ,EAAE,SAAS,EAAE,KAAK,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACxL,YAAY,EACV,SAAS,EACT,SAAS,EACT,UAAU,EACV,SAAS,EACT,WAAW,EACX,SAAS,GACV,MAAM,iBAAiB,CAAC"}
package/dist/index.js CHANGED
@@ -3819,6 +3819,81 @@ function posedBones(doc, timelines, t, plant = {}) {
3819
3819
  return bone2;
3820
3820
  });
3821
3821
  }
3822
+ const LOCAL_CHANNELS = { rotation: 0, x: 0, y: 0, scaleX: 1, scaleY: 1, shearX: 0, shearY: 0 };
3823
+ class StepLocals {
3824
+ constructor(bones, index, setup) {
3825
+ this.bones = bones;
3826
+ this.index = index;
3827
+ this.setup = setup;
3828
+ }
3829
+ read(channel2) {
3830
+ return this.bones[this.index][channel2] ?? LOCAL_CHANNELS[channel2];
3831
+ }
3832
+ write(channel2, value) {
3833
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new CoreInputError(`the adjustment wrote bone "${this.setup.name}" ${channel2} = ${String(value)}, not a finite number`);
3834
+ if (this.bones[this.index] === this.setup) this.bones[this.index] = { ...this.setup };
3835
+ this.bones[this.index][channel2] = value;
3836
+ }
3837
+ get rotation() {
3838
+ return this.read("rotation");
3839
+ }
3840
+ set rotation(v) {
3841
+ this.write("rotation", v);
3842
+ }
3843
+ get x() {
3844
+ return this.read("x");
3845
+ }
3846
+ set x(v) {
3847
+ this.write("x", v);
3848
+ }
3849
+ get y() {
3850
+ return this.read("y");
3851
+ }
3852
+ set y(v) {
3853
+ this.write("y", v);
3854
+ }
3855
+ get scaleX() {
3856
+ return this.read("scaleX");
3857
+ }
3858
+ set scaleX(v) {
3859
+ this.write("scaleX", v);
3860
+ }
3861
+ get scaleY() {
3862
+ return this.read("scaleY");
3863
+ }
3864
+ set scaleY(v) {
3865
+ this.write("scaleY", v);
3866
+ }
3867
+ get shearX() {
3868
+ return this.read("shearX");
3869
+ }
3870
+ set shearX(v) {
3871
+ this.write("shearX", v);
3872
+ }
3873
+ get shearY() {
3874
+ return this.read("shearY");
3875
+ }
3876
+ set shearY(v) {
3877
+ this.write("shearY", v);
3878
+ }
3879
+ }
3880
+ function adjustLocals(doc, bones, adjust) {
3881
+ const views = /* @__PURE__ */ new Map();
3882
+ const names = doc.bones.map((b) => b.name);
3883
+ adjust({
3884
+ names,
3885
+ bone(name) {
3886
+ let view = views.get(name);
3887
+ if (view === void 0) {
3888
+ const index = names.indexOf(name);
3889
+ if (index < 0) throw new CoreInputError(`the adjustment asked for bone ${JSON.stringify(name)}, which is not one of this document's bones`);
3890
+ view = new StepLocals(bones, index, doc.bones[index]);
3891
+ views.set(name, view);
3892
+ }
3893
+ return view;
3894
+ }
3895
+ });
3896
+ }
3822
3897
  const clamp01 = (v) => v < 0 ? 0 : v > 1 ? 1 : v;
3823
3898
  function posedSlots(doc, timelines, t, plant = {}, sliders = [], placeholders) {
3824
3899
  const resolve = plant.shown ?? shownAttachment;
@@ -3895,6 +3970,7 @@ function stepRows(posed) {
3895
3970
  function posedBoneStep(doc, timelines, t, plant, constraints, sliders, step) {
3896
3971
  const active = activeBones(doc);
3897
3972
  const bones = posedBones(doc, timelines, t, plant);
3973
+ if (step?.adjust !== void 0) adjustLocals(doc, bones, step.adjust);
3898
3974
  let world = (plant.evaluate ?? worldTransforms)(bones, active);
3899
3975
  if (constraints !== void 0 && step !== void 0) {
3900
3976
  const records = stepPhysicsRecords(pathDeformed(doc, posedRecords(constraintRecords(doc), constraints, t), timelines, t, plant), constraints.physicsKeyed ?? [], t, active, step.ctx, step.before);
@@ -4515,6 +4591,8 @@ function rawNumber(v) {
4515
4591
  function constraintRecords(doc) {
4516
4592
  return doc.constraints.flatMap((c) => c.record === void 0 ? [] : [c.record]);
4517
4593
  }
4594
+ const NOT_ADMITTED = [];
4595
+ const POSED_BLOCKS = ["setup.bones", "setup.slots", "setup.drawOrder", "setup.attachments", "setup.clips", "setup.clipped"];
4518
4596
  const activeOfView = /* @__PURE__ */ new WeakMap();
4519
4597
  function activeBones(doc) {
4520
4598
  const kept = activeOfView.get(doc);
@@ -4582,6 +4660,97 @@ function resolveShown(doc, slotName, placeholder) {
4582
4660
  const agree = rows.every((r) => r.name === rows[0].name && r.path === rows[0].path);
4583
4661
  return agree ? filling[0] : { conflict: rows.map((r) => ({ skin: r.skin, shown: r.name, path: r.path })) };
4584
4662
  }
4663
+ function poseSetup(doc, plant = {}, physics) {
4664
+ const evaluate = plant.evaluate ?? worldTransforms;
4665
+ const resolve = plant.shown ?? shownAttachment;
4666
+ const colour = plant.colour ?? readColour;
4667
+ const blend = plant.blend ?? readBlend;
4668
+ const round = plant.round ?? gridRound;
4669
+ const bonesWhy = constraintsAbsentWhy(doc, solverRules(plant.solver));
4670
+ let bones = null;
4671
+ let setupWorld = null;
4672
+ const applied = [];
4673
+ if (bonesWhy === null) {
4674
+ const active = activeBones(doc);
4675
+ const records = constraintRecords(doc);
4676
+ setupWorld = applyConstraints(doc.bones, evaluate(doc.bones, active), active, plant.constraints ? plant.constraints(records) : records, null, applied, physics, void 0, solverRules(plant.solver));
4677
+ const world = setupWorld;
4678
+ bones = doc.bones.map((b) => {
4679
+ const t = world.get(b.name);
4680
+ if (t === void 0) throw new CoreInputError(`the evaluator returned no transform for bone "${b.name}"`);
4681
+ return [b.name, round(t.worldX), round(t.worldY), round(t.a), round(t.b), round(t.c), round(t.d), active.has(b.name) ? 1 : 0, b.parent ?? null];
4682
+ });
4683
+ }
4684
+ const conflicts = [];
4685
+ const slotRows = [];
4686
+ const liveBones = activeBones(doc);
4687
+ const slotGate = plant.slotTimelines ?? slotTimelinesApply;
4688
+ for (const slot of doc.slots) {
4689
+ const pose = {
4690
+ placeholder: slot.setup,
4691
+ light: slot.color === void 0 ? [1, 1, 1, 1] : [...colour(slot.color)],
4692
+ dark: slot.dark === void 0 ? null : readColour(slot.dark).slice(0, 3)
4693
+ };
4694
+ if (slotGate(doc, slot, liveBones)) applySliderSlots(slot.name, pose, applied);
4695
+ const posedRecord = { ...slot, setup: pose.placeholder };
4696
+ const shown = pose.placeholder === null ? null : resolve(doc, posedRecord);
4697
+ if (shown !== null && "conflict" in shown) {
4698
+ conflicts.push(`slot "${slot.name}" placeholder "${pose.placeholder}" is filled by skins ${shown.conflict.map((c) => `"${c.skin}" (shows ${JSON.stringify(c.shown)}, path ${JSON.stringify(c.path)})`).join(", ")}`);
4699
+ continue;
4700
+ }
4701
+ const row = shown === null ? null : shownRow(shown);
4702
+ const [r, g, b, a] = pose.light;
4703
+ const dark = pose.dark;
4704
+ slotRows.push([
4705
+ slot.name,
4706
+ row === null ? null : row.name,
4707
+ round(r),
4708
+ round(g),
4709
+ round(b),
4710
+ round(a),
4711
+ dark === null ? null : [round(dark[0]), round(dark[1]), round(dark[2])],
4712
+ row === null ? null : row.path,
4713
+ blend(slot)
4714
+ ]);
4715
+ }
4716
+ const slotsWhy = (bonesWhy === null ? null : slidersWhy(doc)) ?? (conflicts.length === 0 ? null : `${conflicts.join("; ")} — under --skin all the LAST of them in the Spine file's skin order wins, and that order is the emitter's (default first, the rest in the editor's order), not the model's; the per-skin dumps (--skin <name>) judge it`);
4717
+ const slots = slotsWhy === null ? slotRows : null;
4718
+ const upstream = [bonesWhy === null ? null : `setup.bones is absent (${bonesWhy}), and every vertex goes through a bone's world matrix`, slotsWhy === null ? null : "setup.slots is absent, so what a slot shows is not posed"].filter((x) => x !== null);
4719
+ const orderWhy = bonesWhy === null ? null : slidersKeyingWhy(doc, "drawOrder");
4720
+ let drawOrder = null;
4721
+ if (orderWhy === null) {
4722
+ let order = doc.slots.map((_s, i) => i);
4723
+ for (const app of applied) order = (plant.drawOrder ?? drawOrderAt)(doc.slots.length, app.timelines.drawOrder, app.at) ?? order;
4724
+ drawOrder = order.map((i) => doc.slots[i].name);
4725
+ }
4726
+ let attachments = null;
4727
+ let clips = null;
4728
+ let clipsWhy = upstream.length === 0 ? null : upstream.join("; ");
4729
+ let attachmentsWhy = clipsWhy;
4730
+ let clipped = null;
4731
+ let clippedWhy = clipsWhy ?? orderWhy;
4732
+ let shownInOrder = null;
4733
+ if (clipsWhy === null && setupWorld !== null && drawOrder !== null) {
4734
+ const world = setupWorld;
4735
+ const placeholders = new Map(doc.slots.map((sl) => [sl.name, sl.setup]));
4736
+ const states = attachmentStates(doc, resolve, placeholders, null, applied, plant);
4737
+ const rank = new Map(drawOrder.map((n, i) => [n, i]));
4738
+ const shown = [...states.shown].sort((a, b) => (rank.get(a.slot) ?? 0) - (rank.get(b.slot) ?? 0));
4739
+ shownInOrder = states.why.length === 0 ? shown : null;
4740
+ const posed = poseGeometry(shown, world, sourceOfDoc(doc), round, { region: plant.region, vertices: plant.vertices }, drawWalkOf(doc, drawOrder, plant));
4741
+ const stateWhy = states.why.length === 0 ? null : `${states.why.join("; ")}`;
4742
+ attachments = stateWhy === null ? posed.attachments : null;
4743
+ attachmentsWhy = stateWhy ?? posed.attachmentsWhy;
4744
+ clips = stateWhy === null ? posed.clips : null;
4745
+ clipsWhy = stateWhy;
4746
+ clipped = stateWhy === null ? posed.clipped : null;
4747
+ clippedWhy = stateWhy ?? posed.clippedWhy;
4748
+ }
4749
+ const why = { "setup.bones": bonesWhy, "setup.slots": slotsWhy, "setup.drawOrder": orderWhy, "setup.attachments": attachmentsWhy, "setup.clips": clipsWhy, "setup.clipped": clippedWhy };
4750
+ const absent = NOT_ADMITTED.map(([block, reason]) => [block, reason]);
4751
+ for (const block of POSED_BLOCKS) if (why[block] !== null) absent.push([block, why[block]]);
4752
+ return { setup: { bones, slots, drawOrder, attachments, clips, clipped }, absent, world: setupWorld, shown: shownInOrder };
4753
+ }
4585
4754
  function drawWalkOf(doc, order, plant = {}) {
4586
4755
  const active = activeBones(doc);
4587
4756
  return {
@@ -4601,6 +4770,22 @@ function sourceOfDoc(doc) {
4601
4770
  return g?.kind === "mesh" ? g.vertices : sourceProblem(doc.skins, skin, slot, source) ?? `the linked mesh's source "${source}" carries no vertices`;
4602
4771
  };
4603
4772
  }
4773
+ function slidersKeyingWhy(doc, group) {
4774
+ const keyed = doc.constraints.flatMap((c) => {
4775
+ if (c.kind !== "slider") return [];
4776
+ const anim = doc.animations.find((a) => a.name === c.animation);
4777
+ return anim !== void 0 && anim.timelines[group].length > 0 ? [`slider "${c.name}" applies animation "${c.animation}", which keys the draw order`] : [];
4778
+ });
4779
+ return keyed.length === 0 ? null : `${keyed.join("; ")} — a slider's time is read off the bones, which are absent`;
4780
+ }
4781
+ function slidersWhy(doc) {
4782
+ const keyed = doc.constraints.flatMap((c) => {
4783
+ if (c.kind !== "slider") return [];
4784
+ const slots = doc.animations.find((a) => a.name === c.animation)?.slots ?? [];
4785
+ return slots.length === 0 ? [] : [`slider "${c.name}" applies animation "${c.animation}", which keys slot(s) ${slots.map((x) => `"${x}"`).join(", ")}`];
4786
+ });
4787
+ return keyed.length === 0 ? null : `${keyed.join("; ")} — the oracle's setup applies sliders, and a slider poses the slots its animation keys at a time read off the bones, which are absent`;
4788
+ }
4604
4789
  function drawOrderOf(shown, order) {
4605
4790
  const rank = new Map(order.map((n, r) => [n, r]));
4606
4791
  return [...shown].sort((a, b) => (rank.get(a.slot) ?? 0) - (rank.get(b.slot) ?? 0));
@@ -4677,6 +4862,22 @@ function rawDrawn(doc, shown, world, order, plant, mode = RAW_MODE) {
4677
4862
  const clipped = geometry.drawnClipped.map((c) => [c[0], c[1], c[2], c[3].map((v) => numberOf(v, `clipped vertex of slot "${c[0]}"`, mode)), c[4].map((v) => numberOf(v, `clipped uv of slot "${c[0]}"`, mode)), c[5]]);
4678
4863
  return { drawn, clips, clipped };
4679
4864
  }
4865
+ function poseRawSetup(doc, plant = {}) {
4866
+ return setupPoseIn(doc, plant, RAW_MODE);
4867
+ }
4868
+ function setupPoseIn(doc, plant, mode) {
4869
+ const { world, shown, slots, drawOrder } = setupPosed(doc, plant, mode);
4870
+ const drawn = rawDrawn(doc, shown, world, drawOrder, plant, mode);
4871
+ return { trackTime: 0, animationTime: 0, bones: rawBones(doc, world), slots, drawOrder, ...drawn, events: [], shown: drawOrderOf(shown, drawOrder) };
4872
+ }
4873
+ function setupPosed(doc, plant, mode) {
4874
+ const posed = poseSetup(doc, { ...plant, ...roundPlant(mode) }, freshStepContext(plant.physicsStep));
4875
+ const absent = posed.absent.filter(([block]) => block !== "setup.clipped");
4876
+ if (absent.length > 0) throw new CoreInputError(`the raw setup pose leaves ${absent.map(([b, why]) => `${b} out (${why})`).join("; ")}`);
4877
+ const { setup, world, shown } = posed;
4878
+ if (world === null || shown === null || setup.slots === null || setup.drawOrder === null) throw new CoreInputError("the raw setup pose was not posed");
4879
+ return { world, shown, slots: setup.slots, drawOrder: setup.drawOrder };
4880
+ }
4680
4881
  function loopedTime(trackTime, duration) {
4681
4882
  return duration === 0 ? 0 : trackTime % duration;
4682
4883
  }
@@ -4694,16 +4895,16 @@ function animationOf(doc, animation) {
4694
4895
  if (anim === void 0) throw new CoreInputError(`animation "${animation}" is not one of this document's [${doc.animations.map((a) => a.name).join(", ")}]`);
4695
4896
  return anim;
4696
4897
  }
4697
- function openWalk(doc, anim, plant, reset, mode, shape) {
4898
+ function openWalk(doc, anim, plant, reset, mode, shape, adjust) {
4698
4899
  const why = constraintsAbsentWhy(doc) ?? pathAnimationsWhy(doc) ?? steppedPreviousPassWhy(doc);
4699
4900
  if (why !== null) throw new CoreInputError(`the raw walk leaves the bones out: ${why}`);
4700
4901
  const ctx = freshStepContext(plant.physicsStep);
4701
4902
  if (mode.loop && mode.wrapResets !== false) ctx.loop = true;
4702
- const track = { doc, anim, duration: anim.timelines.duration, mode, plant, raw: { ...plant, ...roundPlant(mode) }, resolve: plant.shown ?? shownAttachment, shape, ctx, trackTime: 0, last: -1 };
4903
+ const track = { doc, anim, duration: anim.timelines.duration, mode, plant, raw: { ...plant, ...roundPlant(mode) }, resolve: plant.shown ?? shownAttachment, shape, ctx, adjust, trackTime: 0, last: -1 };
4703
4904
  return { track, first: stepWalk(track, 0, true) };
4704
4905
  }
4705
4906
  function stepWalk(track, dt, first = false) {
4706
- const { doc, anim, duration, mode, plant, raw, resolve, shape, ctx } = track;
4907
+ const { doc, anim, duration, mode, plant, raw, resolve, shape, ctx, adjust } = track;
4707
4908
  track.trackTime += dt;
4708
4909
  const trackTime = track.trackTime;
4709
4910
  const last = track.last;
@@ -4711,7 +4912,7 @@ function stepWalk(track, dt, first = false) {
4711
4912
  const before = ctx.time;
4712
4913
  ctx.time += mode.clock === void 0 ? dt : mode.clock(dt, t - (first ? 0 : Math.max(last, 0)));
4713
4914
  const sliders = [];
4714
- const world = shape.rows ? posedBoneWorld(doc, anim.timelines, t, raw, anim.constraints, sliders, { ctx, before }).world : posedBoneWorldAlone(doc, anim.timelines, t, raw, anim.constraints, sliders, { ctx, before });
4915
+ const world = shape.rows ? posedBoneWorld(doc, anim.timelines, t, raw, anim.constraints, sliders, { ctx, before, adjust }).world : posedBoneWorldAlone(doc, anim.timelines, t, raw, anim.constraints, sliders, { ctx, before, adjust });
4715
4916
  if (first) ctx.phase = "update";
4716
4917
  const placeholders = /* @__PURE__ */ new Map();
4717
4918
  const slots = posedSlots(doc, anim.timelines, t, raw, sliders, placeholders);
@@ -4731,8 +4932,8 @@ function stepWalk(track, dt, first = false) {
4731
4932
  track.last = t;
4732
4933
  return pose;
4733
4934
  }
4734
- function openRawTrack(doc, animation, plant, reset, mode) {
4735
- return openWalk(doc, animationOf(doc, animation), plant, reset, mode, RAW_SHAPE);
4935
+ function openRawTrack(doc, animation, plant, reset, mode, adjust) {
4936
+ return openWalk(doc, animationOf(doc, animation), plant, reset, mode, RAW_SHAPE, adjust);
4736
4937
  }
4737
4938
  function stepRawTrack(track, dt) {
4738
4939
  return stepWalk(track, dt);
@@ -4741,7 +4942,8 @@ const HELD = { loop: false, keep: false };
4741
4942
  const LOOPED = { loop: true, keep: false, live: true };
4742
4943
  function openTrack(doc, animation, options, plant = {}) {
4743
4944
  if (typeof options !== "object" || options === null || typeof options.loop !== "boolean") throw new CoreInputError(`the live track's options.loop is ${JSON.stringify(options?.loop)}, not true or false`);
4744
- const { track, first } = openRawTrack(doc, animation, plant, options.reset, options.loop ? LOOPED : HELD);
4945
+ if (options.adjust !== void 0 && typeof options.adjust !== "function") throw new CoreInputError(`the live track's options.adjust is ${typeof options.adjust}, not a function`);
4946
+ const { track, first } = openRawTrack(doc, animation, plant, options.reset, options.loop ? LOOPED : HELD, options.adjust);
4745
4947
  return new Track(track, first);
4746
4948
  }
4747
4949
  class Track {
@@ -4771,7 +4973,7 @@ class Track {
4771
4973
  this.latest = pose.animationTime;
4772
4974
  return pose;
4773
4975
  } catch (err) {
4774
- if (err instanceof CoreInputError) this.refused = `${n} (${err.message})`;
4976
+ this.refused = `${n} (${err instanceof Error ? err.message : String(err)})`;
4775
4977
  throw err;
4776
4978
  }
4777
4979
  }
@@ -7095,6 +7297,12 @@ class RigPlayer {
7095
7297
  loop = true;
7096
7298
  paused = false;
7097
7299
  pose = null;
7300
+ setup = null;
7301
+ adjustFn = null;
7302
+ /** Handed to every track the player opens; reads the current `adjust` so setting it later needs no reopen. */
7303
+ adjustHook = (locals) => {
7304
+ if (this.adjustFn !== null) this.adjustFn(locals);
7305
+ };
7098
7306
  listeners = {
7099
7307
  event: /* @__PURE__ */ new Set(),
7100
7308
  complete: /* @__PURE__ */ new Set()
@@ -7108,6 +7316,7 @@ class RigPlayer {
7108
7316
  this.root = root;
7109
7317
  this.assets = assets;
7110
7318
  this.meshBackend = options.meshBackend;
7319
+ this.adjustFn = options.adjust ?? null;
7111
7320
  this.active = this.create(options.mode ?? "dom");
7112
7321
  }
7113
7322
  /**
@@ -7216,6 +7425,106 @@ class RigPlayer {
7216
7425
  if (this.pose === null) return;
7217
7426
  this.renderer.render(this.posedFrame());
7218
7427
  }
7428
+ /**
7429
+ * The per-pose adjustment of bone locals (`BoneAdjust`), or null for none.
7430
+ * It is applied by every track the player opens — `play`, the reopen inside
7431
+ * `seek` — and by the pose drawn again after `mode` changes, on every pose
7432
+ * the animation is applied to, the first one included. Setting it re-poses
7433
+ * the pose the player holds at once (`repose`), so a held or paused player
7434
+ * shows the change on its next `draw` without a step.
7435
+ */
7436
+ get adjust() {
7437
+ return this.adjustFn;
7438
+ }
7439
+ set adjust(fn) {
7440
+ if (fn !== null && typeof fn !== "function") throw new Error(`adjust must be a function or null, got ${typeof fn}`);
7441
+ this.adjustFn = fn;
7442
+ this.repose();
7443
+ }
7444
+ /**
7445
+ * Poses the held pose again at the same track time, through the current
7446
+ * `adjust`, without stepping, firing or drawing. A paused player and a zero
7447
+ * step re-pose nothing, so a caller whose adjustment reads its own state
7448
+ * (a mouth driven while the body is held) calls this when that state
7449
+ * changed, then `draw`. Nothing before `play`.
7450
+ */
7451
+ repose() {
7452
+ if (this.track === null || this.pose === null) return;
7453
+ this.pose = this.track.step(0);
7454
+ }
7455
+ /**
7456
+ * Where `name` is in the pose the player holds — the pose of the last
7457
+ * `advance`/`frame`/`seek`, or the setup pose before `play`. Read off the
7458
+ * core's pose, so it is the same in `dom` and `canvas` mode and costs nothing
7459
+ * until called. An unknown bone is refused by name.
7460
+ */
7461
+ bone(name) {
7462
+ const pose = this.posed();
7463
+ for (const bone2 of pose.bones) {
7464
+ if (bone2.name !== name) continue;
7465
+ return {
7466
+ name,
7467
+ active: bone2.active,
7468
+ x: bone2.worldX,
7469
+ y: bone2.worldY,
7470
+ a: bone2.a,
7471
+ b: bone2.b,
7472
+ c: bone2.c,
7473
+ d: bone2.d,
7474
+ rotationX: bone2.rotationX,
7475
+ rotationY: bone2.rotationY,
7476
+ scaleX: bone2.scaleX,
7477
+ scaleY: bone2.scaleY
7478
+ };
7479
+ }
7480
+ throw new Error(`no bone named ${JSON.stringify(name)} in this document (bones: ${pose.bones.map((b) => b.name).join(", ")})`);
7481
+ }
7482
+ /**
7483
+ * The box of what the pose the player holds draws, in world units, Y up:
7484
+ * every region's four corners and every mesh's vertices, in the same frame
7485
+ * record the renderer takes (`posedFrame`). A slot under a clip counts only
7486
+ * inside the clip polygon's own box (an inverse clip removes nothing from
7487
+ * the count); bounding boxes, paths and points are not drawn and do not
7488
+ * count. `null` when nothing draws. The same numbers in `dom` and `canvas`
7489
+ * mode; nothing is computed until called.
7490
+ */
7491
+ bounds() {
7492
+ const frame2 = this.pose === null ? frameOf(this.source, this.posed(), this.color) : this.posedFrame();
7493
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
7494
+ for (const slot of frame2.order) {
7495
+ const points = slot.kind === "region" ? slot.corners : slot.vertices;
7496
+ let lo = -Infinity, lb = -Infinity, hi = Infinity, hb = Infinity;
7497
+ if (slot.clip && !slot.clip.inverse) {
7498
+ const c = slot.clip.polygon;
7499
+ lo = Infinity;
7500
+ lb = Infinity;
7501
+ hi = -Infinity;
7502
+ hb = -Infinity;
7503
+ for (let i = 0; i < c.length; i += 2) {
7504
+ if (c[i] < lo) lo = c[i];
7505
+ if (c[i] > hi) hi = c[i];
7506
+ if (c[i + 1] < lb) lb = c[i + 1];
7507
+ if (c[i + 1] > hb) hb = c[i + 1];
7508
+ }
7509
+ }
7510
+ for (let i = 0; i < points.length; i += 2) {
7511
+ const x = Math.min(Math.max(points[i], lo), hi);
7512
+ const y = Math.min(Math.max(points[i + 1], lb), hb);
7513
+ if (x < minX) minX = x;
7514
+ if (x > maxX) maxX = x;
7515
+ if (y < minY) minY = y;
7516
+ if (y > maxY) maxY = y;
7517
+ }
7518
+ }
7519
+ if (minX === Infinity) return null;
7520
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
7521
+ }
7522
+ /** The pose the reads above see: the track's, or the setup pose before `play` (posed once). */
7523
+ posed() {
7524
+ if (this.pose !== null) return this.pose;
7525
+ if (this.setup === null) this.setup = poseRawSetup(this.view);
7526
+ return this.setup;
7527
+ }
7219
7528
  /** The pose the player holds, as the frame record the renderer draws. */
7220
7529
  posedFrame() {
7221
7530
  if (this.pose === null) return { order: [] };
@@ -7228,7 +7537,7 @@ class RigPlayer {
7228
7537
  this.pose = null;
7229
7538
  }
7230
7539
  open() {
7231
- this.track = openTrack(this.view, this.animation, { loop: this.loop, reset: "animation" });
7540
+ this.track = openTrack(this.view, this.animation, { loop: this.loop, reset: "animation", adjust: this.adjustHook });
7232
7541
  this.pose = this.track.first;
7233
7542
  }
7234
7543
  /**