@rig-c/core 2.74.1 → 2.76.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/docs/AUTHORING.md CHANGED
@@ -5578,6 +5578,43 @@ skeleton's units and axes, and its change of rotation as `physicsRotate(0, 0,
5578
5578
  Δ)`, before the `step` that poses the frame. Nothing set, every pose is the
5579
5579
  one the track posed before these existed (`CO57`).
5580
5580
 
5581
+ Both `LiveTrack` and `LiveTracks` also accept an optional last `scope` argument
5582
+ for movement, a readonly array of **exact physics constraint names** (play #197):
5583
+
5584
+ ```ts
5585
+ track.physicsTranslate(dx, dy, ['hair_left', 'hair_right']);
5586
+ track.physicsRotate(pivotX, pivotY, deltaDegrees, ['hair_left', 'hair_right']);
5587
+ ```
5588
+
5589
+ Omitted or `undefined` means every existing state, exactly as before. `[]`
5590
+ selects none; repeated names select once, in the existing state order. Every
5591
+ name is checked against the document before any state changes: an unknown name
5592
+ is refused as `physics movement scope: no constraint "name"`; a non-physics
5593
+ constraint is refused naming its kind. A non-array scope or non-string name
5594
+ also refuses. Finite movement/pivot checks still apply, including an empty
5595
+ scope. A declared inactive constraint is a valid selection; movement creates
5596
+ no state for it. No document, parameter override, clock or held pose is changed.
5597
+
5598
+ These are synchronous **before-evaluation** inputs: only the selected remembered
5599
+ origins (`ux`, `uy`, `cx`, `cy`) change, so the next `step` sees extra apparent
5600
+ motion. Distances and the explicit pivot are skeleton world units, x right,
5601
+ y up; the angle is degrees CCW **in the XY plane**, not yaw or pitch. The turn
5602
+ is the displacement of each remembered origin around the pivot, not a direct
5603
+ angular impulse; an origin at the pivot has no displacement. The caller derives
5604
+ any projected yaw/pitch displacement from its own geometry and depth. Do not
5605
+ supply it again for movement the bone itself already makes.
5606
+
5607
+ The ordinary step still owns reset, inertia, limits and integration. A reset
5608
+ or first active update can overwrite this input; an intervening `step(0)` may
5609
+ consume it without the intended positive-time response. There is no queue or
5610
+ motion-history replay. Input writes leave all unselected states unchanged;
5611
+ after stepping, exact isolation holds for independent chains. Descendants or
5612
+ constraints that read a selected bone may react through their normal solver
5613
+ dependencies. Scope does not suppress that coupling or expand itself to cover it.
5614
+ `CO64` checks single/composed tracks, both resets, exact independent-state
5615
+ isolation, native per-constraint operations, empty/all/repeated selections and
5616
+ atomic named refusals; the existing `CO56`/`CO57` cover unscoped movement.
5617
+
5581
5618
  For a live preview of one named constraint (issue #1541), both `LiveTrack` and
5582
5619
  `LiveTracks` on `rig-c/core` expose:
5583
5620
 
@@ -12610,7 +12647,7 @@ verdict on those figures.
12610
12647
  | <a id="CE_COMPILE_ALPHA_CLIP_CONFLICT"></a>`CE_COMPILE_ALPHA_CLIP_CONFLICT` | Names authored fields or convex/inverse flags conflicting with generated inside-clipping geometry. Use one authored/generated form.<br>Exact fixed diagnostic chunks (named values between chunks vary): ⟦: alphaClip conflicts with [⟧<br>⟦vertexCount⟧<br>⟦vertices⟧<br>⟦weights⟧<br>⟦boneIndexing⟧<br>⟦, ⟧<br>⟦], convex=⟧<br>⟦, inverse=⟧<br>⟦; required ordinary inside clipping with generated geometry only. Remove authored geometry/flags.⟧ |
12611
12648
  | <a id="CE_COMPILE_ALPHA_CLIP_DEFORM"></a>`CE_COMPILE_ALPHA_CLIP_DEFORM` | Names animation and source/clip key/frame counts that cannot share the existing projection contract. Give matching explicit supported transform keys in compatible actual frames, with constant world shares where needed, or author the corresponding clip geometry and motion; weighted binding does not copy source deform.<br>Exact fixed diagnostic chunks (named values between chunks vary): ⟦ alphaClip from "⟧<br>⟦"⟧<br>⟦: animation "⟧<br>⟦" has ⟧<br>⟦ source and ⟧<br>⟦ clip keys; source frame=⟧<br>⟦no vertex geometry⟧<br>⟦clip frame=⟧<br>⟦, constant world influence shares=⟧<br>⟦Required matching explicit affine/depth-yaw/pitch transforms in compatible DeformGeometry frames, ⟧<br>⟦with constant shares for world-space transfer. Fix the named frame/keys or author corresponding clip motion.⟧ |
12612
12649
 
12613
- ## 15. Posing in place for a player — the runtime poser (stage 2)
12650
+ ## 15. Posing in place for a player — the runtime poser (stage 3)
12614
12651
 
12615
12652
  rigc poses a model document twice over, on purpose (issue #1591). The **oracle**
12616
12653
  (`src/core/`) is the reference: each pose is a pure function of the document,
@@ -12645,11 +12682,11 @@ exact resolved table identity `(skin, model.slots[attachment.slot].name, placeho
12645
12682
  The display `name` may be shared by different placeholders; it is never a table
12646
12683
  key to guess the source from. These load-time strings do not change the pose path.
12647
12684
 
12648
- | | what stage 2 poses |
12685
+ | | what stage 3 poses |
12649
12686
  | --- | --- |
12650
12687
  | bones | local and world transforms, all five inherit modes, skin-required bones under the view's skin (an inactive bone is zeros, as the oracle poses it) |
12651
12688
  | timelines | `rotate`, `translate(x/y)`, `scale(x/y)`, `shear(x/y)`; a slot's `attachment`, `rgba`, `rgb` and `alpha`; ik and transform timelines; linear, stepped and Bézier keys, each read from a per-pose key cursor |
12652
- | constraints | one- and two-bone ik; world-space, absolute transform constraints; the document's order, each followed by the bones below what it moved posed again |
12689
+ | constraints | physics with its exact clock, reset and parameter timelines; value-mapped sliders with bone/slot/deform/physics composition; one- and two-bone ik; world-space, absolute transform constraints; the document's order, each followed by the bones below what it moved posed again |
12653
12690
  | vertices | regions, meshes, weighted meshes and linked meshes, written into the pose's buffer |
12654
12691
  | deform | weighted and unweighted mesh timelines, linear/stepped/Bézier, per-pose key cursors and in-place buffers; linked meshes inherit their source's timelines only with `timelines: true` |
12655
12692
  | skins and events | named/no-skin views with their skin-required bones and constraints; event delivery in key order over `(last, time]`, tail then head across a wrap |
@@ -12692,9 +12729,43 @@ back to the definition's **volume**, matching the measured runtime rather than
12692
12729
  inventing a correction. An explicit `ignore: ['events']` diagnostic still mutes
12693
12730
  the list; normal compilation never does.
12694
12731
 
12732
+ Physics is explicit on a direct pose: `apply(time, 'none')` leaves physics inert;
12733
+ `apply(time, 'reset', delta)` or `apply(time, 'update', delta)` first applies
12734
+ reset keys over `(last, time]` against the old clock, then adds `delta` to the
12735
+ physics clock and solves. A backwards animation time crosses the tail then head
12736
+ of the interval, as a looping track does. `step(dt)` updates physics and the
12737
+ looping track; it takes the initial reset at animation zero if needed.
12738
+ `resetPhysics('animation')` starts at that animation's pose zero;
12739
+ `resetPhysics('setup')` starts from setup before applying the animation.
12740
+ The physics clock is a running sum, never the animation time; constructor option
12741
+ `{ clock: physicsClockStep }` can select the render clock without changing track
12742
+ time. `physicsTime` exposes that sum. A non-finite or negative delta/clock result
12743
+ throws `RuntimeInputError` naming the call. `physicsTranslate(x, y)` and
12744
+ `physicsRotate(x, y, degrees)` shift the previous origins; `setEnvironment({
12745
+ wind: [x, y], gravity: [x, y] })` supplies the skeleton directions (positive y
12746
+ points down), all numbers finite. Offsets, velocities, lags and buffers belong
12747
+ to each pose; a zero physics mix does not consume its constraint's clock.
12748
+
12749
+ A slider reads its dial where earlier constraints left it: local or world
12750
+ property mapped as `to + (value - from) * scale`, clamped at zero or looped by
12751
+ its animation duration. A boneless slider uses its keyed `time`. The selected
12752
+ animation is applied at that value, never advanced as a track, and fires no
12753
+ events or physics reset keys. Mix zero does not re-pose bones; nonzero mix
12754
+ re-poses every keyed bone even before its first key. Its bone targets compose
12755
+ in authored order, including repeated names; each entry uses the same setup
12756
+ values and blends from the current pose. Ordinary tracks still read only the
12757
+ last target for each bone. An earlier unsupported inherit target is refused
12758
+ when a slider applies it, even if the track's last target would hide it.
12759
+ Absolute/additive bone and
12760
+ deform blends and slot switches follow the oracle, including direct scale and
12761
+ colour targets at mix one. Slider physics writes last one pass and affect only
12762
+ constraints later in update order. An unsupported timeline in a slider's
12763
+ animation refuses the whole model under that timeline's existing feature name,
12764
+ so an unselected track cannot hide a construct the slider always applies.
12765
+
12695
12766
  **Everything else is refused at load, by name.** `RuntimeRefusal.missing` lists
12696
12767
  every construct met, each `{ feature, detail, animation }`, and the message
12697
- reads `not posed by the runtime poser (stage 2), N construct(s) missing:
12768
+ reads `not posed by the runtime poser (stage 3), N construct(s) missing:
12698
12769
  <feature>: <detail>; …`. A model-level feature refuses the whole model; an
12699
12770
  animation-level one refuses that animation alone (`model.refused`;
12700
12771
  `model.animation(name)` and `setAnimation(name)` throw its refusal) and every
@@ -12703,7 +12774,7 @@ refused: nothing is posed approximately.
12703
12774
 
12704
12775
  | feature | refuses | what it is |
12705
12776
  | --- | --- | --- |
12706
- | `path-constraint`, `physics-constraint`, `slider-constraint` | the model | a constraint of that kind |
12777
+ | `path-constraint` | the model | a constraint of that kind |
12707
12778
  | `local-transform`, `additive-transform` | the model | a transform constraint with `localSource` or `localTarget`, or `additive` |
12708
12779
  | `inactive-constrained-bone` | the model | an applied ik or transform constraint naming a bone the view leaves inactive (what the oracle writes there depends on the pass before) |
12709
12780
  | `noscale-world-target` | the model | a world-space transform onto a `noScale` or `noScaleOrReflection` bone, whose local values would be read back in that mode |
@@ -12718,7 +12789,7 @@ this document's [...]`, `apply: the time NaN is not finite`.
12718
12789
 
12719
12790
  **The gate.** `tools/core_gate.ts` runs a third dumper (`runtimeDump` in
12720
12791
  `tools/pose_oracle.ts`) over every row it builds, at the oracle's samples, and
12721
- compares its document with the oracle's in full doubles at tolerance 0 twice:
12792
+ compares both unstepped and stepped documents with the oracle's in full doubles at tolerance 0 twice:
12722
12793
  through `compare` and cell by cell (which reads the bones `compare`'s
12723
12794
  ill-conditioned rule leaves out). One `RUNTIME` line per row reads `IDENTICAL`
12724
12795
  (covered whole and equal), `SKIP` (refused at load, or animations or events left
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/core",
3
- "version": "2.74.1",
3
+ "version": "2.76.0",
4
4
  "description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -645,8 +645,9 @@ export const DEFAULT_PHYSICS_ENVIRONMENT: PhysicsEnvironment = Object.freeze({ w
645
645
  * pose. A constraint with no state yet has not been updated, and its first
646
646
  * update is pending, so the runtime's translation of it is read by nothing.
647
647
  */
648
- export function translatePhysics(ctx: PhysicsStepContext, x: number, y: number): void {
649
- for (const s of ctx.states.values()) {
648
+ export function translatePhysics(ctx: PhysicsStepContext, x: number, y: number, scope?: ReadonlySet<string>): void {
649
+ for (const [name, s] of ctx.states) {
650
+ if (scope !== undefined && !scope.has(name)) continue;
650
651
  s.ux -= x;
651
652
  s.uy -= y;
652
653
  s.cx -= x;
@@ -661,11 +662,12 @@ export function translatePhysics(ctx: PhysicsStepContext, x: number, y: number):
661
662
  * a translation of each constraint by how far that turn carries the origin it
662
663
  * last saw, as `PhysicsConstraint.rotate` computes it.
663
664
  */
664
- export function rotatePhysics(ctx: PhysicsStepContext, x: number, y: number, degrees: number): void {
665
+ export function rotatePhysics(ctx: PhysicsStepContext, x: number, y: number, degrees: number, scope?: ReadonlySet<string>): void {
665
666
  const r = degrees * RUNTIME_RAD;
666
667
  const cos = Math.cos(r);
667
668
  const sin = Math.sin(r);
668
- for (const s of ctx.states.values()) {
669
+ for (const [name, s] of ctx.states) {
670
+ if (scope !== undefined && !scope.has(name)) continue;
669
671
  const dx = s.cx - x;
670
672
  const dy = s.cy - y;
671
673
  const tx = dx * cos - dy * sin - dx;
package/src/core/raw.ts CHANGED
@@ -728,20 +728,36 @@ export function environmentOf(ctx: PhysicsStepContext): PhysicsEnvironment {
728
728
  return ctx.env ?? DEFAULT_PHYSICS_ENVIRONMENT;
729
729
  }
730
730
 
731
+ /** Validate the complete explicit selection before any remembered state changes. */
732
+ export function checkedPhysicsScope(doc: CompiledDocument, scope: readonly string[] | undefined, who: string): ReadonlySet<string> | undefined {
733
+ if (scope === undefined) return undefined;
734
+ if (!Array.isArray(scope)) throw new CoreInputError(`${who} physics movement scope is ${String(scope)}, not an array of physics constraint names`);
735
+ const selected = new Set<string>();
736
+ for (const name of scope) {
737
+ if (typeof name !== 'string') throw new CoreInputError(`${who} physics movement scope name ${String(name)} is not a string`);
738
+ const constraint = doc.constraints.find((c) => c.kind === 'physics' && c.name === name)
739
+ ?? doc.constraints.find((c) => c.name === name);
740
+ if (constraint === undefined) throw new CoreInputError(`${who} physics movement scope: no constraint "${name}"; this document declares [${doc.constraints.map((c) => c.name).join(', ')}]`);
741
+ if (constraint.kind !== 'physics') throw new CoreInputError(`${who} physics movement scope: constraint "${name}" is ${constraint.kind}, not physics`);
742
+ selected.add(name);
743
+ }
744
+ return selected;
745
+ }
746
+
731
747
  /** A live track's `physicsTranslate` (issue #1416): `x` and `y` finite, or refused by name before anything moves; then `translatePhysics`. */
732
- export function translateOn(ctx: PhysicsStepContext, x: number, y: number, who: string): void {
748
+ export function translateOn(ctx: PhysicsStepContext, x: number, y: number, who: string, scope?: ReadonlySet<string>): void {
733
749
  for (const [k, v] of [['x', x], ['y', y]] as const) {
734
750
  if (typeof v !== 'number' || !Number.isFinite(v)) throw new CoreInputError(`${who} physicsTranslate's ${k} is ${String(v)}, not a finite distance in the skeleton's world`);
735
751
  }
736
- translatePhysics(ctx, x, y);
752
+ translatePhysics(ctx, x, y, scope);
737
753
  }
738
754
 
739
755
  /** A live track's `physicsRotate` (issue #1416): `x`, `y` and `degrees` finite, or refused by name before anything moves; then `rotatePhysics`. */
740
- export function rotateOn(ctx: PhysicsStepContext, x: number, y: number, degrees: number, who: string): void {
756
+ export function rotateOn(ctx: PhysicsStepContext, x: number, y: number, degrees: number, who: string, scope?: ReadonlySet<string>): void {
741
757
  for (const [k, v] of [['x', x], ['y', y], ['degrees', degrees]] as const) {
742
758
  if (typeof v !== 'number' || !Number.isFinite(v)) throw new CoreInputError(`${who} physicsRotate's ${k} is ${String(v)}, not a finite number`);
743
759
  }
744
- rotatePhysics(ctx, x, y, degrees);
760
+ rotatePhysics(ctx, x, y, degrees, scope);
745
761
  }
746
762
 
747
763
  /**
package/src/core/track.ts CHANGED
@@ -222,7 +222,7 @@
222
222
  * history, so a seek is "open, then step to the time".
223
223
  */
224
224
  import { CoreInputError, type CompiledDocument } from './index.ts';
225
- import { checkedClock, environmentOf, openRawTrack, rotateOn, setEnvironmentOn, stepRawTrack, translateOn, type RawPose, type RawReset, type TrackState, type WalkMode } from './raw.ts';
225
+ import { checkedClock, checkedPhysicsScope, environmentOf, openRawTrack, rotateOn, setEnvironmentOn, stepRawTrack, translateOn, type RawPose, type RawReset, type TrackState, type WalkMode } from './raw.ts';
226
226
  import type { LocalAdjust, TimelinePlant } from './animation.ts';
227
227
  import type { PhysicsEnvironment } from './constraints_physics.ts';
228
228
  import { LivePhysicsParameters, type PhysicsParameterPatch, type PhysicsParameterRead } from './physics_parameters.ts';
@@ -261,10 +261,12 @@ export interface LiveTrack {
261
261
  readPhysicsParameters(name: string): PhysicsParameterRead;
262
262
  /** Removes the patch for the next pose without resetting velocities, offsets or the clock. */
263
263
  clearPhysicsParameters(name: string): void;
264
- /** spine-core's `Skeleton.physicsTranslate`: the consumer moved the whole rig by `(x, y)` in the skeleton's world (y up) since the last pose, and every physics constraint's next update sees it. */
265
- physicsTranslate(x: number, y: number): void;
266
- /** spine-core's `Skeleton.physicsRotate`: the consumer turned the whole rig by `degrees` (counter-clockwise) about `(x, y)` in the skeleton's world since the last pose. */
267
- physicsRotate(x: number, y: number, degrees: number): void;
264
+ /** Scope omitted: all physics; otherwise exact physics names, validated atomically. Empty selects none, duplicates select once.
265
+ * Movement changes remembered world origins synchronously, before the next evaluation.
266
+ * spine-core's `Skeleton.physicsTranslate`: the consumer moved the whole rig by `(x, y)` in the skeleton's world (y up) since the last pose, and every physics constraint's next update sees it. */
267
+ physicsTranslate(x: number, y: number, scope?: readonly string[]): void;
268
+ /** Same scope contract as physicsTranslate. The consumer turned by `degrees` (counter-clockwise) about explicit `(x, y)` in the skeleton's world; changes remembered origins, not tip direction or angular velocity. */
269
+ physicsRotate(x: number, y: number, degrees: number, scope?: readonly string[]): void;
268
270
  }
269
271
 
270
272
  /** The two modes: a track that holds at its duration is the raw entry's walk; a looping one fires its events across the wrap and cuts its clipped rows (`live`). Neither keeps a non-finite value. */
@@ -336,14 +338,14 @@ class Track implements LiveTrack {
336
338
  this.parameters.clear(name);
337
339
  }
338
340
 
339
- physicsTranslate(x: number, y: number): void {
341
+ physicsTranslate(x: number, y: number, scope?: readonly string[]): void {
340
342
  this.live('physicsTranslate');
341
- translateOn(this.track.ctx, x, y, 'the live track\'s');
343
+ translateOn(this.track.ctx, x, y, 'the live track\'s', checkedPhysicsScope(this.track.doc, scope, 'the live track\'s'));
342
344
  }
343
345
 
344
- physicsRotate(x: number, y: number, degrees: number): void {
346
+ physicsRotate(x: number, y: number, degrees: number, scope?: readonly string[]): void {
345
347
  this.live('physicsRotate');
346
- rotateOn(this.track.ctx, x, y, degrees, 'the live track\'s');
348
+ rotateOn(this.track.ctx, x, y, degrees, 'the live track\'s', checkedPhysicsScope(this.track.doc, scope, 'the live track\'s'));
347
349
  }
348
350
 
349
351
  /** A refused track refuses every call that would move it, naming the step it was refused at. */
@@ -164,7 +164,7 @@ import { activeBones, CoreInputError, rawNumber, shownAttachment, viewColours, W
164
164
  import { channelAt, keyIndexAt, SLOT_COLOUR_TIMELINES, slotRowsFrom, steppedLocalsWorld, type BoneTimelineKind, type CoreKey, type LocalAdjust, type SlotBase, type SlotColourKind, type TimelinePlant } from './animation.ts';
165
165
  import { deformAt, setupArray, type CoreAttachmentTimeline } from './deform.ts';
166
166
  import { eventsFired, type CoreEventKey, type CoreEventRow } from './events.ts';
167
- import { checkedClock, environmentOf, openRawTracksPath, rotateOn, setEnvironmentOn, translateOn, type RawPose, type RawReset, type RawTracksPath } from './raw.ts';
167
+ import { checkedClock, checkedPhysicsScope, environmentOf, openRawTracksPath, rotateOn, setEnvironmentOn, translateOn, type RawPose, type RawReset, type RawTracksPath } from './raw.ts';
168
168
  import type { PhysicsEnvironment } from './constraints_physics.ts';
169
169
  import { LivePhysicsParameters, type PhysicsParameterPatch, type PhysicsParameterRead } from './physics_parameters.ts';
170
170
  import { slotTimelinesApply } from './skins.ts';
@@ -240,10 +240,12 @@ export interface LiveTracks {
240
240
  readPhysicsParameters(name: string): PhysicsParameterRead;
241
241
  /** Removes the patch without resetting the physics history, as LiveTrack.clearPhysicsParameters. */
242
242
  clearPhysicsParameters(name: string): void;
243
- /** spine-core's `Skeleton.physicsTranslate`, as `LiveTrack.physicsTranslate`. */
244
- physicsTranslate(x: number, y: number): void;
245
- /** spine-core's `Skeleton.physicsRotate`, as `LiveTrack.physicsRotate`. */
246
- physicsRotate(x: number, y: number, degrees: number): void;
243
+ /** Scope omitted: all physics; otherwise exact physics names, validated atomically. Empty selects none, duplicates select once.
244
+ * Movement changes remembered world origins synchronously, before the next evaluation.
245
+ * spine-core's `Skeleton.physicsTranslate`, as `LiveTrack.physicsTranslate`. */
246
+ physicsTranslate(x: number, y: number, scope?: readonly string[]): void;
247
+ /** Same exact-name scope and explicit world pivot as `LiveTrack.physicsRotate`. */
248
+ physicsRotate(x: number, y: number, degrees: number, scope?: readonly string[]): void;
247
249
  }
248
250
 
249
251
  /**
@@ -1384,14 +1386,14 @@ class Tracks implements LiveTracks {
1384
1386
  this.parameters.clear(name);
1385
1387
  }
1386
1388
 
1387
- physicsTranslate(x: number, y: number): void {
1389
+ physicsTranslate(x: number, y: number, scope?: readonly string[]): void {
1388
1390
  this.live('physicsTranslate');
1389
- translateOn(this.path.ctx, x, y, 'the live tracks\'');
1391
+ translateOn(this.path.ctx, x, y, 'the live tracks\'', checkedPhysicsScope(this.doc, scope, 'the live tracks\''));
1390
1392
  }
1391
1393
 
1392
- physicsRotate(x: number, y: number, degrees: number): void {
1394
+ physicsRotate(x: number, y: number, degrees: number, scope?: readonly string[]): void {
1393
1395
  this.live('physicsRotate');
1394
- rotateOn(this.path.ctx, x, y, degrees, 'the live tracks\'');
1396
+ rotateOn(this.path.ctx, x, y, degrees, 'the live tracks\'', checkedPhysicsScope(this.doc, scope, 'the live tracks\''));
1395
1397
  }
1396
1398
 
1397
1399
  private live(call: string): void {
@@ -78,7 +78,9 @@ export function compileCurve(keys: readonly CoreKey[], channels: number): Runtim
78
78
  /** The last key at or before `t` (−1 before the first), found from `cursors[slot]` and left there — the header's first read. */
79
79
  export function keyAt(curve: RuntimeCurve, cursors: Int32Array, slot: number, t: number): number {
80
80
  const times = curve.times;
81
- let i = cursors[slot];
81
+ // A slider can evaluate another animation between two reads of this one.
82
+ // Shared cursor slots are only search hints: keep the hint inside THIS curve.
83
+ let i = Math.min(cursors[slot], curve.keys - 1);
82
84
  while (i >= 0 && times[i] > t) i--;
83
85
  while (i + 1 < curve.keys && times[i + 1] <= t) i++;
84
86
  cursors[slot] = i;
@@ -21,12 +21,12 @@
21
21
  * corpus through both at the oracle's samples and compares full doubles at
22
22
  * tolerance 0 (*The runtime poser* there). The oracle is not changed for it.
23
23
  *
24
- * ## Stage 2: what it poses, and what it refuses
24
+ * ## Stage 3: what it poses, and what it refuses
25
25
  *
26
26
  * Bones and their five inherit modes; the rotate, translate, scale and shear
27
27
  * timelines; a slot's attachment and colour (`rgba`, `rgb`, `alpha`)
28
28
  * timelines; weighted/unweighted deform timelines; region, mesh, weighted mesh and linked mesh vertices; one- and
29
- * two-bone ik and world-space, absolute transform constraints with their
29
+ * two-bone ik, world-space absolute transforms, physics and value-mapped sliders with their
30
30
  * timelines; skin-required bones and constraints under the view's skin. One
31
31
  * animation at full weight from the setup pose, at a time or stepped as a
32
32
  * looping track. Everything else is refused at load by name with the list of
@@ -81,4 +81,5 @@ export {
81
81
  L_SHEAR_Y,
82
82
  } from './model.ts';
83
83
  export type { RuntimeAnimation, RuntimeEvent, RuntimeAttachment, RuntimeFeature, RuntimeMissing, RuntimeModel, RuntimeOptions, RuntimePlant, RuntimeSlot } from './model.ts';
84
+ export type { RuntimePoseOptions, RuntimePhysicsPhase } from './pose.ts';
84
85
  export { RuntimePose } from './pose.ts';
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The runtime poser's load-time half (issue #1591, stage 2): a skin view of
2
+ * The runtime poser's load-time half (issue #1591, stage 3): a skin view of
3
3
  * the compiled model (`readModel`, then `underSkin` / `underNoSkin` — the
4
4
  * oracle's reader and its views, read and never written) compiled once into
5
5
  * index-resolved bones and slots, a constraint plan, compiled timelines and
@@ -12,7 +12,7 @@
12
12
  * (`RuntimeMissing`), and:
13
13
  *
14
14
  * - **refuses the model** (`RuntimeRefusal`, every missing feature listed)
15
- * when one is a property of the whole model — a path, physics or slider constraint, a transform constraint reading or
15
+ * when one is a property of the whole model — a path constraint, a transform constraint reading or
16
16
  * writing local values or adding (`localSource`, `localTarget`,
17
17
  * `additive`), an ik or transform constraint over a bone the view leaves
18
18
  * inactive, a world-space transform onto a `noScale` or
@@ -26,9 +26,9 @@
26
26
  * attachment key showing a clipping attachment or a region with no atlas
27
27
  * rectangle. Every other animation of the model is posed.
28
28
  *
29
- * `RUNTIME_FEATURES` is the closed list of what can be named. Stage 2 adds mesh deform timelines to the
30
- * named/no-skin views and event keys, with immutable payloads compiled
31
- * at load. `ignore: ['events']` remains an explicit diagnostic opt-out; it is
29
+ * `RUNTIME_FEATURES` is the closed list of what can be named. Stage 3 adds indexed physics and value-mapped sliders, including their
30
+ * parameter timelines and per-pass writes. Mesh deforms, named/no-skin views
31
+ * and immutable event deliveries remain covered. `ignore: ['events']` remains an explicit diagnostic opt-out; it is
32
32
  * never the default. A merged view with conflicting attachments still refuses
33
33
  * by name, as the oracle itself cannot resolve that view without file order.
34
34
  *
@@ -50,16 +50,16 @@ import { activeBones, constraintRecords, CORE_REGION_KINDS, foldInheritMode, rea
50
50
  import { constraintInactiveWhy, constraintsAbsentWhy, TRANSFORM_PROPERTIES, type CoreIkRecord, type CoreTransformRecord } from '../core/constraints.ts';
51
51
  import { RUNTIME_PI } from '../core/world.ts';
52
52
  import { heldArray, timelineIdentity } from '../core/deform.ts';
53
+ import type { CorePhysicsRecord, PhysicsTimelineKind } from '../core/constraints_physics.ts';
54
+ import type { CoreSliderRecord } from '../core/constraints_slider.ts';
53
55
  import { compileCurve, compileDeformCurve, type RuntimeCurve } from './curves.ts';
54
56
 
55
57
  /** The stage of issue #1591 this poser implements. */
56
- export const RUNTIME_STAGE = 2;
58
+ export const RUNTIME_STAGE = 3;
57
59
 
58
60
  /** Every construct the runtime poser can refuse, by the name its refusals carry. */
59
61
  export const RUNTIME_FEATURES = [
60
62
  'path-constraint',
61
- 'physics-constraint',
62
- 'slider-constraint',
63
63
  'local-transform',
64
64
  'additive-transform',
65
65
  'inactive-constrained-bone',
@@ -126,6 +126,8 @@ export interface RuntimePlant {
126
126
  /** The old restore behavior, which repeats keys up to the restored time. */
127
127
  readonly restoreEventsFromStart?: boolean;
128
128
  /** Omit deform / multiply offsets by weights too early: negative controls only. */
129
+ readonly omitPhysics?: boolean;
130
+ readonly sliderAsTrack?: boolean;
129
131
  readonly omitDeform?: boolean;
130
132
  readonly deformAfterWeights?: boolean;
131
133
  }
@@ -257,7 +259,41 @@ export interface RuntimeTransform {
257
259
  readonly clamp: boolean;
258
260
  }
259
261
 
260
- export type RuntimeConstraint = RuntimeIk | RuntimeTransform;
262
+ export interface RuntimePhysics {
263
+ readonly kind: 'physics';
264
+ readonly name: string;
265
+ readonly index: number;
266
+ readonly applied: boolean;
267
+ readonly bones: Int32Array;
268
+ readonly setup: CorePhysicsRecord;
269
+ }
270
+
271
+ export interface RuntimeSlider {
272
+ readonly kind: 'slider';
273
+ readonly name: string;
274
+ readonly index: number;
275
+ readonly applied: boolean;
276
+ readonly bones: Int32Array;
277
+ readonly bone: number;
278
+ readonly property: number;
279
+ readonly setup: CoreSliderRecord;
280
+ /** Resolved after every animation is compiled. Never a track. */
281
+ animation: RuntimeAnimation;
282
+ }
283
+
284
+ export interface RuntimePhysicsTimeline {
285
+ readonly kind: PhysicsTimelineKind;
286
+ readonly targets: Int32Array;
287
+ readonly curve: RuntimeCurve;
288
+ readonly cursor: number;
289
+ }
290
+ export interface RuntimeSliderTimeline {
291
+ readonly time: RuntimeCurve | null;
292
+ readonly mix: RuntimeCurve | null;
293
+ readonly timeCursor: number;
294
+ readonly mixCursor: number;
295
+ }
296
+ export type RuntimeConstraint = RuntimeIk | RuntimeTransform | RuntimePhysics | RuntimeSlider;
261
297
 
262
298
  /** A bone timeline: the bone, its curve, and per channel the local field it writes and whether it multiplies the setup value (scale) or adds to it. */
263
299
  export interface RuntimeBoneTimeline {
@@ -310,7 +346,11 @@ export interface RuntimeAnimation {
310
346
  readonly name: string;
311
347
  readonly duration: number;
312
348
  readonly bones: readonly RuntimeBoneTimeline[];
349
+ /** Every authored target in order; sliders compose duplicates that tracks discard. */
350
+ readonly sliderBones: readonly RuntimeBoneTimeline[];
313
351
  readonly deforms: readonly RuntimeDeformTimeline[];
352
+ readonly physics: readonly RuntimePhysicsTimeline[];
353
+ readonly sliders: ReadonlyArray<RuntimeSliderTimeline | null>;
314
354
  readonly slots: readonly RuntimeSlotTimeline[];
315
355
  /** Per constraint of the plan, its timeline in this animation or `null`. */
316
356
  readonly constraints: ReadonlyArray<RuntimeConstraintTimeline | null>;
@@ -326,6 +366,8 @@ export interface RuntimeModel {
326
366
  /** Control-only boundary defect; fixed at load. */
327
367
  readonly omitDeform: boolean;
328
368
  readonly deformAfterWeights: boolean;
369
+ readonly omitPhysics: boolean;
370
+ readonly sliderAsTrack: boolean;
329
371
  readonly deformCount: number;
330
372
  readonly eventWrapOffByOne: boolean;
331
373
  readonly restoreEventsFromStart: boolean;
@@ -449,13 +491,21 @@ export function compileRuntime(doc: CompiledDocument, options: RuntimeOptions =
449
491
 
450
492
  // --- constraints, in the document's order ---
451
493
  const constraints: RuntimeConstraint[] = [];
452
- for (const c of doc.constraints) if (c.kind === 'path' || c.kind === 'physics' || c.kind === 'slider') refusals.note(`${c.kind}-constraint`, `${c.kind} constraint "${c.name}"`);
494
+ for (const c of doc.constraints) if (c.kind === 'path') refusals.note(`${c.kind}-constraint`, `${c.kind} constraint "${c.name}"`);
453
495
  for (const r of constraintRecords(doc)) {
454
- if (r.kind !== 'ik' && r.kind !== 'transform') continue;
496
+ if (r.kind === 'path') continue;
455
497
  const applied = constraintInactiveWhy(r, active) === null;
456
- const bones = Int32Array.from(r.bones.map(indexOf));
457
- if (applied && r.bones.some((b) => !active.has(b))) refusals.note('inactive-constrained-bone', `${r.kind} constraint "${r.name}" names bone(s) ${r.bones.filter((b) => !active.has(b)).map((b) => `"${b}"`).join(', ')}, which the view leaves inactive — what the oracle writes there depends on the pass before`);
498
+ const bones = Int32Array.from((r.kind === 'physics' ? [r.bone] : r.kind === 'slider' ? r.timelines.bones.filter(t => t.timelines.length > 0).map(t => t.name) : r.bones).map(indexOf));
458
499
  const index = constraints.length;
500
+ if (r.kind === 'physics') {
501
+ constraints.push({ kind: 'physics', name: r.name, index, applied, bones, setup: r });
502
+ continue;
503
+ }
504
+ if (r.kind === 'slider') {
505
+ constraints.push({ kind: 'slider', name: r.name, index, applied, bones, setup: r, bone: r.bone === null ? -1 : indexOf(r.bone), property: TRANSFORM_PROPERTIES.indexOf(r.property), animation: null as unknown as RuntimeAnimation });
506
+ continue;
507
+ }
508
+ if (applied && r.bones.some((b) => !active.has(b))) refusals.note('inactive-constrained-bone', `${r.kind} constraint "${r.name}" names bone(s) ${r.bones.filter((b) => !active.has(b)).map((b) => `"${b}"`).join(', ')}, which the view leaves inactive — what the oracle writes there depends on the pass before`);
459
509
  if (r.kind === 'ik') constraints.push(compileIk(r, index, applied, bones, indexOf));
460
510
  else {
461
511
  if (r.localSource || r.localTarget) refusals.note('local-transform', `transform constraint "${r.name}" ${[r.localSource ? 'reads local values (localSource)' : '', r.localTarget ? 'writes local values (localTarget)' : ''].filter((x) => x !== '').join(' and ')}`);
@@ -573,9 +623,16 @@ export function compileRuntime(doc: CompiledDocument, options: RuntimeOptions =
573
623
  }
574
624
  }
575
625
 
626
+ for (const c of constraints) if (c.kind === 'slider') {
627
+ const a = animations.get(c.setup.animation);
628
+ if (a !== undefined) c.animation = a;
629
+ else for (const m of refusals.byAnimation.get(c.setup.animation) ?? []) refusals.note(m.feature, `slider "${c.name}" applies refused animation "${c.setup.animation}": ${m.detail}`);
630
+ }
576
631
  if (refusals.model.length > 0) throw new RuntimeRefusal(refusals.model);
577
632
  const names = doc.animations.map((a) => a.name);
578
633
  return {
634
+ omitPhysics: plant.omitPhysics === true,
635
+ sliderAsTrack: plant.sliderAsTrack === true,
579
636
  omitDeform: plant.omitDeform === true,
580
637
  deformAfterWeights: plant.deformAfterWeights === true,
581
638
  deformCount,
@@ -714,18 +771,32 @@ function compileAnimation(
714
771
 
715
772
  const lastBy = <T extends { name: string }>(targets: readonly T[]): Map<string, T> => new Map(targets.map((t) => [t.name, t]));
716
773
  const bones: RuntimeBoneTimeline[] = [];
717
- for (const target of lastBy(tl.bones).values()) {
774
+ type BoneTarget = (typeof tl.bones)[number];
775
+ const byTarget = new Map<BoneTarget, RuntimeBoneTimeline[]>();
776
+ const compileBones = (target: BoneTarget, forSlider: boolean): RuntimeBoneTimeline[] => {
777
+ const cached = byTarget.get(target);
778
+ if (cached !== undefined) return cached;
779
+ const keyed: RuntimeBoneTimeline[] = [];
780
+ byTarget.set(target, keyed);
718
781
  const bone = boneIndex.get(target.name);
719
- if (bone === undefined) continue;
782
+ if (bone === undefined) return keyed;
720
783
  for (const t of target.timelines) {
721
784
  if (t.kind === 'inherit') {
722
- refusals.note('inherit-timeline', `bone "${target.name}" keys its inherit mode`, name);
785
+ if (!forSlider) refusals.note('inherit-timeline', `bone "${target.name}" keys its inherit mode`, name);
786
+ else for (const c of constraints) if (c.kind === 'slider' && c.setup.animation === name) {
787
+ refusals.note('inherit-timeline', `slider "${c.name}" applies animation "${name}" whose bone "${target.name}" keys its inherit mode`);
788
+ }
723
789
  continue;
724
790
  }
725
791
  const spec = BONE_KINDS[t.kind];
726
- bones.push({ bone, curve: compileCurve(t.keys, spec.length), cursor: cursors++, fields: Int32Array.from(spec.map((s) => s[0])), multiply: Uint8Array.from(spec.map((s) => s[1])) });
792
+ keyed.push({ bone, curve: compileCurve(t.keys, spec.length), cursor: cursors++, fields: Int32Array.from(spec.map((s) => s[0])), multiply: Uint8Array.from(spec.map((s) => s[1])) });
727
793
  }
728
- }
794
+ return keyed;
795
+ };
796
+ // Keep the track's last-target Map order and reuse those exact curves/cursors.
797
+ for (const target of lastBy(tl.bones).values()) bones.push(...compileBones(target, false));
798
+ const sliderBones = constraints.some(c => c.kind === 'slider' && c.setup.animation === name)
799
+ ? tl.bones.flatMap(target => compileBones(target, true)) : [];
729
800
  const slotTimelines: RuntimeSlotTimeline[] = [];
730
801
  const keyedSlots = [...lastBy(tl.slots).values()].flatMap((target) => {
731
802
  const si = slotIndex.get(target.name);
@@ -752,12 +823,22 @@ function compileAnimation(
752
823
  const keyed = anim.constraints.ik.find((x) => x.name === c.name);
753
824
  if (keyed === undefined) continue;
754
825
  constraintTimelines[c.index] = { constraint: c.index, curve: compileCurve(keyed.keys, 2), cursor: cursors++, flags: Uint8Array.from(keyed.keys.map((k) => ikFlags(k.flags.bendPositive, k.flags.compress, k.flags.stretch))) };
755
- } else {
826
+ } else if (c.kind === 'transform') {
756
827
  const keyed = anim.constraints.transform.find((x) => x.name === c.name);
757
828
  if (keyed === undefined) continue;
758
829
  constraintTimelines[c.index] = { constraint: c.index, curve: compileCurve(keyed.keys, TRANSFORM_PROPERTIES.length), cursor: cursors++, flags: null };
759
830
  }
760
831
  }
832
+ const sliders = constraints.map((c): RuntimeSliderTimeline | null => {
833
+ if (c.kind !== 'slider') return null;
834
+ const keyed = anim.constraints.slider.find(t => t.name === c.name);
835
+ if (keyed === undefined) return null;
836
+ return { time: keyed.time === null ? null : compileCurve(keyed.time, 1), mix: keyed.mix === null ? null : compileCurve(keyed.mix, 1), timeCursor: cursors++, mixCursor: cursors++ };
837
+ });
838
+ const physics: RuntimePhysicsTimeline[] = (anim.constraints.physicsKeyed ?? []).map(t => ({
839
+ kind: t.kind, cursor: cursors++, curve: compileCurve(t.keys, t.kind === 'reset' ? 0 : 1),
840
+ targets: Int32Array.from(constraints.filter((c): c is RuntimePhysics => c.kind === 'physics' && c.applied && (t.name === '*' ? t.kind === 'reset' || c.setup.global[t.kind] : c.name === t.name)).map(c => c.index)),
841
+ }));
761
842
  const deforms: RuntimeDeformTimeline[] = deform.map(a => {
762
843
  const g = doc.skins.find(s => s.name === a.skin)?.attachments[a.slot]?.[a.attachment]?.geometry;
763
844
  if (g === undefined || g.kind === 'region' || g.kind === 'linkedmesh') throw new RuntimeInputError(`deform "${a.skin}/${a.slot}/${a.attachment}" has no vertex geometry (readModel refuses it first)`);
@@ -765,5 +846,5 @@ function compileAnimation(
765
846
  const arrays = keys.map(k => heldArray(g.vertices, k));
766
847
  return { identity: `${a.skin}/${a.slot}/${a.attachment}`, cursor: cursors++, curve: compileDeformCurve(keys), count: arrays[0].length, held: Float64Array.from(arrays.flat()) };
767
848
  });
768
- return { name, duration: tl.duration, deforms, bones, slots: slotTimelines, constraints: constraintTimelines, cursors, events: tl.events.length, eventKeys: Object.freeze(ignoreEvents ? [] : tl.events.map(key => Object.freeze({ name: key.name, time: key.time, int: key.int, float: key.float, string: key.string, volume: key.volume ?? 0, balance: key.balance ?? 0 }))) };
849
+ return { name, duration: tl.duration, physics, sliders, deforms, bones, sliderBones, slots: slotTimelines, constraints: constraintTimelines, cursors, events: tl.events.length, eventKeys: Object.freeze(ignoreEvents ? [] : tl.events.map(key => Object.freeze({ name: key.name, time: key.time, int: key.int, float: key.float, string: key.string, volume: key.volume ?? 0, balance: key.balance ?? 0 }))) };
769
850
  }
@@ -0,0 +1,235 @@
1
+ /** Indexed in-place physics. Arithmetic and operation order mirror the measured oracle;
2
+ * state is allocated once per pose, never during an update. */
3
+ import type { CorePhysicsRecord } from '../core/constraints_physics.ts';
4
+ import { RUNTIME_PI } from '../core/world.ts';
5
+ const PI2 = RUNTIME_PI * 2;
6
+ const INV_PI2 = 1 / PI2;
7
+ export interface PhysicsState {
8
+ /** The next update takes the bone where it is and integrates nothing: set by a reset and on a constraint never updated. */
9
+ pending: boolean;
10
+ lastTime: number;
11
+ remaining: number;
12
+ ux: number;
13
+ uy: number;
14
+ cx: number;
15
+ cy: number;
16
+ tx: number;
17
+ ty: number;
18
+ xOffset: number;
19
+ xLag: number;
20
+ xVelocity: number;
21
+ yOffset: number;
22
+ yLag: number;
23
+ yVelocity: number;
24
+ rotateOffset: number;
25
+ rotateLag: number;
26
+ rotateVelocity: number;
27
+ scaleOffset: number;
28
+ scaleLag: number;
29
+ scaleVelocity: number;
30
+ }
31
+
32
+ /** A constraint's state before its first update: pending, its clock at 0. */
33
+ export function freshPhysicsState(): PhysicsState {
34
+ return { pending: true, lastTime: 0, remaining: 0, ux: 0, uy: 0, cx: 0, cy: 0, tx: 0, ty: 0, xOffset: 0, xLag: 0, xVelocity: 0, yOffset: 0, yLag: 0, yVelocity: 0, rotateOffset: 0, rotateLag: 0, rotateVelocity: 0, scaleOffset: 0, scaleLag: 0, scaleVelocity: 0 };
35
+ }
36
+
37
+ /** A reset: offsets, velocities and lags cleared, nothing remaining, the clock read at `time`, pending. */
38
+ export function resetPhysicsState(s: PhysicsState, time: number): void {
39
+ s.remaining = 0;
40
+ s.lastTime = time;
41
+ s.pending = true;
42
+ s.xOffset = s.xLag = s.xVelocity = 0;
43
+ s.yOffset = s.yLag = s.yVelocity = 0;
44
+ s.rotateOffset = s.rotateLag = s.rotateVelocity = 0;
45
+ s.scaleOffset = s.scaleLag = s.scaleVelocity = 0;
46
+ }
47
+
48
+ export function stepRuntimePhysics(r: CorePhysicsRecord, s: PhysicsState, w: Float64Array, boneOffset: number, length: number, time: number, reset: boolean, env: Float64Array): boolean {
49
+ const mix = r.mix;
50
+ if (mix === 0) return false;
51
+ const x = r.x > 0, y = r.y > 0, rotateOrShearX = r.rotate > 0 || r.shearX > 0, scaleX = r.scaleX > 0;
52
+ const l = length;
53
+ if (reset) resetPhysicsState(s, time);
54
+ const delta = Math.max(time - s.lastTime, 0);
55
+ // The interpolation weight of the previous update, before this one's time is added: the rotate and the scale-only inertia read the pose as it was shown.
56
+ const shown = Math.max(0, 1 - s.remaining / r.step);
57
+ s.remaining += delta;
58
+ s.lastTime = time;
59
+ const bx = w[boneOffset + 4];
60
+ const by = w[boneOffset + 5];
61
+ let z = 0;
62
+ if (s.pending) {
63
+ s.pending = false;
64
+ s.ux = bx;
65
+ s.uy = by;
66
+ } else {
67
+ let a = s.remaining;
68
+ const i = r.inertia;
69
+ const t = r.step;
70
+ const f = r.referenceScale;
71
+ let d = -1;
72
+ const qx = r.limit * delta;
73
+ const qy = qx;
74
+ if (x || y) {
75
+ if (x) {
76
+ const u = (s.ux - bx) * i;
77
+ s.xOffset += u > qx ? qx : u < -qx ? -qx : u;
78
+ s.ux = bx;
79
+ }
80
+ if (y) {
81
+ const u = (s.uy - by) * i;
82
+ s.yOffset += u > qy ? qy : u < -qy ? -qy : u;
83
+ s.uy = by;
84
+ }
85
+ if (a >= t) {
86
+ d = Math.pow(r.damping, 60 * t);
87
+ const m = r.massInverse * t;
88
+ const e = r.strength;
89
+ const wind = r.wind * f;
90
+ const g = r.gravity * f;
91
+ // The environment's directions (issue #1416), as the runtime weighs them; the skeleton's scale is 1 here, so its factor is left out.
92
+ const ax = wind * env[0] + g * env[2];
93
+ const ay = wind * env[1] + g * env[3];
94
+ const xs = s.xOffset;
95
+ const ys = s.yOffset;
96
+ do {
97
+ if (x) {
98
+ s.xVelocity += (ax - s.xOffset * e) * m;
99
+ s.xOffset += s.xVelocity * t;
100
+ s.xVelocity *= d;
101
+ }
102
+ if (y) {
103
+ s.yVelocity -= (ay + s.yOffset * e) * m;
104
+ s.yOffset += s.yVelocity * t;
105
+ s.yVelocity *= d;
106
+ }
107
+ a -= t;
108
+ } while (a >= t);
109
+ s.xLag = s.xOffset - xs;
110
+ s.yLag = s.yOffset - ys;
111
+ }
112
+ z = Math.max(0, 1 - a / t);
113
+ if (x) w[boneOffset + 4] += (s.xOffset - s.xLag * z) * mix * r.x;
114
+ if (y) w[boneOffset + 5] += (s.yOffset - s.yLag * z) * mix * r.y;
115
+ }
116
+ if (rotateOrShearX || scaleX) {
117
+ const ca = Math.atan2(w[boneOffset + 2], w[boneOffset + 0]);
118
+ let c = 0;
119
+ let sn = 0;
120
+ let mr = 0;
121
+ let dx = s.cx - w[boneOffset + 4];
122
+ let dy = s.cy - w[boneOffset + 5];
123
+ if (dx > qx) dx = qx;
124
+ else if (dx < -qx) dx = -qx;
125
+ if (dy > qy) dy = qy;
126
+ else if (dy < -qy) dy = -qy;
127
+ if (rotateOrShearX) {
128
+ mr = (r.rotate + r.shearX) * mix;
129
+ const lagged = s.rotateLag * shown;
130
+ let rr = Math.atan2(dy + s.ty, dx + s.tx) - ca - (s.rotateOffset - lagged) * mr;
131
+ s.rotateOffset += (rr - Math.ceil(rr * INV_PI2 - 0.5) * PI2) * i;
132
+ rr = (s.rotateOffset - lagged) * mr + ca;
133
+ c = Math.cos(rr);
134
+ sn = Math.sin(rr);
135
+ if (scaleX) {
136
+ rr = l * Math.sqrt(w[boneOffset + 0] * w[boneOffset + 0] + w[boneOffset + 2] * w[boneOffset + 2]);
137
+ if (rr > 0) s.scaleOffset += ((dx * c + dy * sn) * i) / rr;
138
+ }
139
+ } else {
140
+ c = Math.cos(ca);
141
+ sn = Math.sin(ca);
142
+ const rr = l * Math.sqrt(w[boneOffset + 0] * w[boneOffset + 0] + w[boneOffset + 2] * w[boneOffset + 2]) - s.scaleLag * shown;
143
+ if (rr > 0) s.scaleOffset += ((dx * c + dy * sn) * i) / rr;
144
+ }
145
+ a = s.remaining;
146
+ if (a >= t) {
147
+ if (d === -1) d = Math.pow(r.damping, 60 * t);
148
+ const m = r.massInverse * t;
149
+ const e = r.strength;
150
+ const ax = r.wind * env[0] + r.gravity * env[2];
151
+ const ay = r.wind * env[1] + r.gravity * env[3];
152
+ const h = l / f;
153
+ const rs = s.rotateOffset;
154
+ const ss = s.scaleOffset;
155
+ for (;;) {
156
+ a -= t;
157
+ if (scaleX) {
158
+ s.scaleVelocity += (ax * c - ay * sn - s.scaleOffset * e) * m;
159
+ s.scaleOffset += s.scaleVelocity * t;
160
+ s.scaleVelocity *= d;
161
+ }
162
+ if (rotateOrShearX) {
163
+ s.rotateVelocity -= ((ax * sn + ay * c) * h + s.rotateOffset * e) * m;
164
+ s.rotateOffset += s.rotateVelocity * t;
165
+ s.rotateVelocity *= d;
166
+ if (a < t) break;
167
+ const rr = s.rotateOffset * mr + ca;
168
+ c = Math.cos(rr);
169
+ sn = Math.sin(rr);
170
+ } else if (a < t) break;
171
+ }
172
+ s.rotateLag = s.rotateOffset - rs;
173
+ s.scaleLag = s.scaleOffset - ss;
174
+ }
175
+ z = Math.max(0, 1 - a / t);
176
+ }
177
+ s.remaining = a;
178
+ }
179
+ s.cx = w[boneOffset + 4];
180
+ s.cy = w[boneOffset + 5];
181
+ if (rotateOrShearX) {
182
+ let angle = (s.rotateOffset - s.rotateLag * z) * mix;
183
+ let sn = 0;
184
+ let c = 0;
185
+ let a = 0;
186
+ if (r.shearX > 0) {
187
+ let rr = 0;
188
+ if (r.rotate > 0) {
189
+ // The rotation's share turns the y column here, and the x column with the shear below.
190
+ rr = angle * r.rotate;
191
+ sn = Math.sin(rr);
192
+ c = Math.cos(rr);
193
+ a = w[boneOffset + 1];
194
+ w[boneOffset + 1] = c * a - sn * w[boneOffset + 3];
195
+ w[boneOffset + 3] = sn * a + c * w[boneOffset + 3];
196
+ }
197
+ rr += angle * r.shearX;
198
+ sn = Math.sin(rr);
199
+ c = Math.cos(rr);
200
+ a = w[boneOffset + 0];
201
+ w[boneOffset + 0] = c * a - sn * w[boneOffset + 2];
202
+ w[boneOffset + 2] = sn * a + c * w[boneOffset + 2];
203
+ } else {
204
+ angle *= r.rotate;
205
+ sn = Math.sin(angle);
206
+ c = Math.cos(angle);
207
+ a = w[boneOffset + 0];
208
+ w[boneOffset + 0] = c * a - sn * w[boneOffset + 2];
209
+ w[boneOffset + 2] = sn * a + c * w[boneOffset + 2];
210
+ a = w[boneOffset + 1];
211
+ w[boneOffset + 1] = c * a - sn * w[boneOffset + 3];
212
+ w[boneOffset + 3] = sn * a + c * w[boneOffset + 3];
213
+ }
214
+ }
215
+ if (scaleX) {
216
+ const k = 1 + (s.scaleOffset - s.scaleLag * z) * mix * r.scaleX;
217
+ w[boneOffset + 0] *= k;
218
+ w[boneOffset + 2] *= k;
219
+ // The transverse column follows the authored mode (issue #1453), after
220
+ // the X column: the native Volume branch bounds reciprocal compression
221
+ // below 0.7; it is not an area-preservation promise on that branch.
222
+ if (r.scaleYMode === 'Uniform') {
223
+ w[boneOffset + 1] *= k;
224
+ w[boneOffset + 3] *= k;
225
+ } else if (r.scaleYMode === 'Volume') {
226
+ const magnitude = Math.abs(k);
227
+ const transverse = magnitude >= 0.7 ? 1 / magnitude : 4 - 3.67347 * magnitude;
228
+ w[boneOffset + 1] *= transverse;
229
+ w[boneOffset + 3] *= transverse;
230
+ }
231
+ }
232
+ s.tx = l * w[boneOffset + 0];
233
+ s.ty = l * w[boneOffset + 2];
234
+ return true;
235
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The runtime poser's per-instance half (issue #1591, stage 2): one
2
+ * The runtime poser's per-instance half (issue #1591, stage 3): one
3
3
  * `RuntimePose` per skeleton on screen, every buffer allocated when it is
4
4
  * made and overwritten in place on every pose, so that posing a frame
5
5
  * allocates nothing.
@@ -13,8 +13,9 @@
13
13
  * document's order with their timelines' values at `t`, each followed by the
14
14
  * bones below what it moved posed again; then each slot's colour and what it
15
15
  * shows; then, when asked, every shown attachment's world vertices. Nothing
16
- * of one pose survives into the next but the key cursors, which only make
17
- * the key search start nearer.
16
+ * of one unstepped pose survives but key cursors. A stepped pose also keeps
17
+ * its physics clock, offsets, velocities and lags; sliders select values
18
+ * within this pose rather than advancing an independent track.
18
19
  *
19
20
  * ## The arithmetic is the oracle's
20
21
  *
@@ -37,6 +38,8 @@
37
38
  * exactly 0 touching nothing; an inactive bone posed as zeros by the
38
39
  * hierarchy and never posed again by a constraint, nor the bones below it.
39
40
  */
41
+ import type { CorePhysicsRecord } from '../core/constraints_physics.ts';
42
+ import { freshPhysicsState, resetPhysicsState, stepRuntimePhysics, type PhysicsState } from './physics.ts';
40
43
  import { COLLAPSED_X_AXIS_SQ, RUNTIME_DEG, RUNTIME_PI } from '../core/world.ts';
41
44
  import { channelValue, keyAt, deformFraction, CURVE_STEPPED } from './curves.ts';
42
45
  import {
@@ -75,6 +78,8 @@ import {
75
78
  type RuntimeIk,
76
79
  type RuntimeModel,
77
80
  type RuntimeTransform,
81
+ type RuntimeSlider,
82
+ type RuntimePhysicsTimeline,
78
83
  } from './model.ts';
79
84
 
80
85
  const DEG = RUNTIME_DEG;
@@ -98,6 +103,10 @@ function volumeScaleY(scaleY: number, s: number): number {
98
103
 
99
104
  const clamp01 = (v: number): number => (v < 0 ? 0 : v > 1 ? 1 : v);
100
105
 
106
+ /** A caller-supplied physics clock; animation/track time still uses dt itself. */
107
+ export interface RuntimePoseOptions { readonly clock?: (dt: number) => number }
108
+ export type RuntimePhysicsPhase = 'none' | 'reset' | 'update';
109
+
101
110
  /** One skeleton's pose, every value in place. Read `world`, `light`, `shown` and `vertices` after a pose. */
102
111
  export class RuntimePose {
103
112
  readonly model: RuntimeModel;
@@ -139,8 +148,29 @@ export class RuntimePose {
139
148
  private readonly visit: Uint8Array;
140
149
  private readonly w = new Float64Array(WORLDS);
141
150
 
142
- constructor(model: RuntimeModel) {
151
+ /** Sum of caller-supplied physics deltas, independent of animation time. */
152
+ physicsTime = 0;
153
+ private physicsLast = -1;
154
+ private physicsPhase: RuntimePhysicsPhase = 'none';
155
+ private physicsReady = false;
156
+ private readonly hasPhysics: boolean;
157
+ private readonly physicsStates: PhysicsState[];
158
+ private readonly physicsRecords: Array<CorePhysicsRecord | null>;
159
+ private readonly environment = new Float64Array([1, 0, 0, 1]);
160
+ private readonly sliderTime: Float64Array;
161
+ private readonly sliderMix: Float64Array;
162
+ private readonly sliderAt: Float64Array;
163
+ private readonly clock: ((dt: number) => number) | undefined;
164
+
165
+ constructor(model: RuntimeModel, options: RuntimePoseOptions = {}) {
143
166
  this.model = model;
167
+ this.clock = options.clock;
168
+ this.hasPhysics = model.constraints.some(c => c.kind === 'physics');
169
+ this.physicsStates = model.constraints.map(() => freshPhysicsState());
170
+ this.physicsRecords = model.constraints.map(c => c.kind === 'physics' ? { ...c.setup } : null);
171
+ this.sliderTime = new Float64Array(model.constraints.length);
172
+ this.sliderMix = new Float64Array(model.constraints.length);
173
+ this.sliderAt = new Float64Array(model.constraints.length);
144
174
  const nb = model.boneNames.length;
145
175
  const ns = model.slots.length;
146
176
  const nc = model.constraints.length;
@@ -168,6 +198,10 @@ export class RuntimePose {
168
198
  /** The animation this pose plays (a covered one: `model.animation` throws a `RuntimeRefusal` for a refused one), from track time 0. */
169
199
  setAnimation(name: string): void {
170
200
  this.current = this.model.animation(name);
201
+ this.physicsTime = 0;
202
+ this.physicsLast = -1;
203
+ this.physicsReady = false;
204
+ for (let k = 0; k < this.physicsStates.length; k++) resetPhysicsState(this.physicsStates[k], 0);
171
205
  this.cursors.fill(-1);
172
206
  this.trackTime = 0;
173
207
  this.time = 0;
@@ -190,40 +224,57 @@ export class RuntimePose {
190
224
  }
191
225
 
192
226
  /** The setup pose: every constraint applied with its own values, every slot as it is set up. */
193
- setupPose(): void {
227
+ setupPose(physics: RuntimePhysicsPhase = 'none'): void {
228
+ this.physicsPhase = physics;
194
229
  this.current = null;
195
230
  this.time = 0;
196
231
  this.eventTime = -1;
197
232
  this.fired.length = 0;
233
+ this.physicsValues(null, 0, false);
198
234
  this.locals.set(this.model.setup);
199
235
  this.hierarchy();
200
236
  this.constraintValues(null, 0);
201
237
  this.solveConstraints();
202
- this.slotValues(null, 0);
203
- this.deformValues(null, 0);
238
+ this.attachmentValues(null, 0);
204
239
  }
205
240
 
206
241
  /** The animation posed at its time `t`, from the setup pose (the header). */
207
- apply(t: number): void {
242
+ apply(t: number, physics: RuntimePhysicsPhase = 'none', delta = 0): void {
208
243
  const anim = this.current;
209
244
  if (anim === null) throw new RuntimeInputError('apply: no animation is set (setAnimation first)');
210
245
  if (!Number.isFinite(t)) throw new RuntimeInputError(`apply: the time ${t} is not finite`);
246
+ if (!Number.isFinite(delta) || delta < 0) throw new RuntimeInputError(`apply: physics delta ${delta} is not finite and at or above 0`);
247
+ this.physicsPhase = physics;
248
+ // Reset keys see the old clock. The skeleton moves by delta only after application.
249
+ this.physicsValues(anim, t, physics !== 'none');
250
+ if (physics !== 'none') {
251
+ this.physicsTime += delta;
252
+ this.physicsReady = true;
253
+ }
211
254
  this.collectEvents(anim, t);
212
255
  this.time = t;
213
256
  this.boneTimelines(anim, t);
214
257
  this.hierarchy();
215
258
  this.constraintValues(anim, t);
216
259
  this.solveConstraints();
217
- this.slotValues(anim, t);
218
- this.deformValues(anim, t);
260
+ this.attachmentValues(anim, t);
219
261
  }
220
262
 
221
263
  /** One step of a looping track: the track time moved by `dt`, the animation posed at it wrapped by the duration (`loopedTime` in src/core/raw.ts). */
222
264
  step(dt: number): void {
223
265
  const anim = this.current;
224
266
  if (anim === null) throw new RuntimeInputError('step: no animation is set (setAnimation first)');
267
+ if (!Number.isFinite(dt) || dt < 0) throw new RuntimeInputError(`step: delta ${dt} is not finite and at or above 0`);
268
+ if (!this.physicsReady && this.hasPhysics) {
269
+ // Initial physics setup must not consume the event interval of this step.
270
+ const lastEventTime = this.eventTime;
271
+ this.apply(this.time, 'reset', 0);
272
+ this.eventTime = lastEventTime;
273
+ }
274
+ const moved = this.clock === undefined ? dt : this.clock(dt);
275
+ if (!Number.isFinite(moved) || moved < 0) throw new RuntimeInputError(`step: physics clock moved by ${moved}, not finite and at or above 0`);
225
276
  this.trackTime += dt;
226
- this.apply(anim.duration === 0 ? 0 : this.trackTime % anim.duration);
277
+ this.apply(anim.duration === 0 ? 0 : this.trackTime % anim.duration, 'update', moved);
227
278
  }
228
279
 
229
280
  /** The oracle interval (last,t], tail then head on a wrap; never copies a payload. */
@@ -427,9 +478,24 @@ export class RuntimePose {
427
478
  this.ikSoftness[k] = c.softness + (channelValue(tl.curve, i, 1, t) - c.softness) * 1;
428
479
  this.ikFlags[k] = (tl.flags as Uint8Array)[i];
429
480
  }
430
- } else {
481
+ } else if (c.kind === 'transform') {
431
482
  const o = k * 6;
432
483
  for (let p = 0; p < 6; p++) this.mixes[o + p] = tl === null || i < 0 ? c.mixes[p] : c.mixes[p] + (channelValue(tl.curve, i, p, t) - c.mixes[p]) * 1;
484
+ } else if (c.kind === 'slider') {
485
+ const keyed = anim === null ? null : anim.sliders[k];
486
+ let time = c.setup.time, mix = c.setup.mix;
487
+ if (keyed !== null) {
488
+ if (keyed.time !== null) {
489
+ const q = keyAt(keyed.time, this.cursors, keyed.timeCursor, t);
490
+ if (q >= 0) time += (channelValue(keyed.time, q, 0, t) - time) * 1;
491
+ }
492
+ if (keyed.mix !== null) {
493
+ const q = keyAt(keyed.mix, this.cursors, keyed.mixCursor, t);
494
+ if (q >= 0) mix += (channelValue(keyed.mix, q, 0, t) - mix) * 1;
495
+ }
496
+ }
497
+ this.sliderTime[k] = time;
498
+ this.sliderMix[k] = mix;
433
499
  }
434
500
  }
435
501
  }
@@ -441,7 +507,222 @@ export class RuntimePose {
441
507
  const c = cs[k];
442
508
  if (!c.applied) continue;
443
509
  if (c.kind === 'ik') this.solveIk(c, k);
444
- else this.solveTransform(c, k);
510
+ else if (c.kind === 'transform') this.solveTransform(c, k);
511
+ else if (c.kind === 'slider') this.solveSlider(c, k);
512
+ else if (this.physicsPhase !== 'none' && !this.model.omitPhysics) {
513
+ const b = c.bones[0];
514
+ if (stepRuntimePhysics(this.physicsRecords[k]!, this.physicsStates[k], this.world, b * WORLDS, this.model.lengths[b], this.physicsTime, this.physicsPhase === 'reset', this.environment)) {
515
+ this.changed[0] = this.inWorld[0] = b;
516
+ this.repose(1, 1);
517
+ }
518
+ }
519
+ }
520
+ }
521
+
522
+ /** Reset where the consumer starts: the setup pose or animation pose zero. */
523
+ resetPhysics(at: 'setup' | 'animation' = 'animation'): void {
524
+ this.physicsTime = 0;
525
+ this.physicsLast = -1;
526
+ for (let k = 0; k < this.physicsStates.length; k++) resetPhysicsState(this.physicsStates[k], 0);
527
+ if (at === 'animation') this.apply(0, 'reset', 0);
528
+ else {
529
+ const current = this.current;
530
+ this.setupPose('reset');
531
+ this.current = current;
532
+ }
533
+ this.physicsReady = true;
534
+ }
535
+
536
+ /** External motion changes the previous origin, never the authored locals. */
537
+ physicsTranslate(x: number, y: number): void {
538
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new RuntimeInputError('physicsTranslate: x and y must be finite');
539
+ for (let k = 0; k < this.physicsStates.length; k++) {
540
+ const s = this.physicsStates[k];
541
+ s.ux -= x; s.uy -= y; s.cx -= x; s.cy -= y;
542
+ }
543
+ }
544
+
545
+ physicsRotate(x: number, y: number, degrees: number): void {
546
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(degrees)) throw new RuntimeInputError('physicsRotate: x, y and degrees must be finite');
547
+ const r = degrees * RAD, cos = Math.cos(r), sin = Math.sin(r);
548
+ for (let k = 0; k < this.physicsStates.length; k++) {
549
+ const s = this.physicsStates[k];
550
+ const dx = s.cx - x, dy = s.cy - y;
551
+ const tx = dx * cos - dy * sin - dx, ty = dx * sin + dy * cos - dy;
552
+ s.ux -= tx; s.uy -= ty; s.cx -= tx; s.cy -= ty;
553
+ }
554
+ }
555
+
556
+ setEnvironment(env: { readonly wind: readonly [number, number]; readonly gravity: readonly [number, number] }): void {
557
+ if (!Number.isFinite(env.wind[0]) || !Number.isFinite(env.wind[1]) || !Number.isFinite(env.gravity[0]) || !Number.isFinite(env.gravity[1])) throw new RuntimeInputError('setEnvironment: wind and gravity must be finite');
558
+ this.environment[0] = env.wind[0]; this.environment[1] = env.wind[1];
559
+ this.environment[2] = env.gravity[0]; this.environment[3] = env.gravity[1];
560
+ }
561
+
562
+ /** The per-pass values start at setup, before any slider writes. */
563
+ private physicsValues(anim: RuntimeAnimation | null, t: number, resets: boolean): void {
564
+ const cs = this.model.constraints;
565
+ for (let k = 0; k < cs.length; k++) {
566
+ const c = cs[k];
567
+ if (c.kind !== 'physics') continue;
568
+ const r = this.physicsRecords[k]!;
569
+ r.inertia = c.setup.inertia; r.strength = c.setup.strength; r.damping = c.setup.damping;
570
+ r.massInverse = c.setup.massInverse; r.wind = c.setup.wind; r.gravity = c.setup.gravity; r.mix = c.setup.mix;
571
+ }
572
+ if (anim !== null) this.physicsTimelines(anim.physics, t, 1, false, true, resets);
573
+ if (resets) this.physicsLast = t;
574
+ }
575
+
576
+ private physicsTimelines(timelines: readonly RuntimePhysicsTimeline[], t: number, alpha: number, additive: boolean, setup: boolean, resets = false): void {
577
+ for (let q = 0; q < timelines.length; q++) {
578
+ const tl = timelines[q];
579
+ if (tl.kind === 'reset') {
580
+ if (!resets) continue; // a slider applies at one value; no key is crossed
581
+ let crossed = false;
582
+ const last = this.physicsLast;
583
+ for (let i = 0; i < tl.curve.keys; i++) {
584
+ const time = tl.curve.times[i];
585
+ if (t < last ? time > last || time <= t : time > last && time <= t) crossed = true;
586
+ }
587
+ if (crossed) for (let j = 0; j < tl.targets.length; j++) resetPhysicsState(this.physicsStates[tl.targets[j]], this.physicsTime);
588
+ continue;
589
+ }
590
+ const i = keyAt(tl.curve, this.cursors, tl.cursor, t);
591
+ if (i < 0) {
592
+ // A track's before-first key restores setup even if an earlier global
593
+ // timeline wrote this parameter. A slider's before-first key writes nothing.
594
+ if (setup) for (let j = 0; j < tl.targets.length; j++) {
595
+ const k = tl.targets[j], r = this.physicsRecords[k]!;
596
+ const original = (this.model.constraints[k] as { setup: CorePhysicsRecord }).setup;
597
+ if (tl.kind === 'mass') r.massInverse = original.massInverse;
598
+ else r[tl.kind] = original[tl.kind];
599
+ }
600
+ continue;
601
+ }
602
+ const v = channelValue(tl.curve, i, 0, t);
603
+ for (let j = 0; j < tl.targets.length; j++) {
604
+ const k = tl.targets[j], r = this.physicsRecords[k]!;
605
+ const from = setup ? (this.model.constraints[k] as { setup: CorePhysicsRecord }).setup : r;
606
+ if (tl.kind === 'mass') {
607
+ const mass = 1 / from.massInverse;
608
+ r.massInverse = 1 / (mass + (v - mass) * alpha);
609
+ } else {
610
+ r[tl.kind] = additive && (tl.kind === 'wind' || tl.kind === 'gravity') ? r[tl.kind] + v * alpha : from[tl.kind] + (v - from[tl.kind]) * alpha;
611
+ }
612
+ }
613
+ }
614
+ }
615
+
616
+ /** Read the dial where previous constraints left it; this value is never track time. */
617
+ private solveSlider(c: RuntimeSlider, k: number): void {
618
+ const alpha = this.sliderMix[k];
619
+ if (alpha === 0) return;
620
+ let time = this.sliderTime[k];
621
+ const r = c.setup, a = c.animation;
622
+ if (c.bone >= 0) {
623
+ const b = c.bone, w = b * WORLDS, l = b * LOCALS;
624
+ let value: number;
625
+ if (r.local) {
626
+ const field = c.property === 0 ? L_ROTATION : c.property === 1 ? L_X : c.property === 2 ? L_Y : c.property === 3 ? L_SCALE_X : c.property === 4 ? L_SCALE_Y : L_SHEAR_Y;
627
+ value = this.locals[l + field] + 0;
628
+ } else {
629
+ const W = this.world, x = W[w], y = W[w + 2], bx = W[w + 1], by = W[w + 3];
630
+ switch (c.property) {
631
+ case 0: value = Math.atan2(y, x) * DEG + (x * by - bx * y > 0 ? 0 : -0); if (value < 0) value += 360; break;
632
+ case 1: value = x * 0 + bx * 0 + W[w + W_X]; break;
633
+ case 2: value = y * 0 + by * 0 + W[w + W_Y]; break;
634
+ case 3: value = Math.sqrt(x * x + y * y) + 0; break;
635
+ case 4: value = Math.sqrt(bx * bx + by * by) + 0; break;
636
+ default: value = (Math.atan2(by, bx) - Math.atan2(y, x)) * DEG - 90 + 0;
637
+ }
638
+ }
639
+ time = r.to + (value - r.from) * r.scale;
640
+ time = r.loop ? a.duration === 0 ? 0 : a.duration + time % a.duration : Math.max(0, time);
641
+ }
642
+ const at = this.model.sliderAsTrack ? this.time : r.loop && a.duration !== 0 ? time % a.duration : time;
643
+ this.sliderAt[k] = at;
644
+ if (this.physicsPhase !== 'none') this.physicsTimelines(a.physics, at, alpha, r.additive, false);
645
+ const L = this.locals, S = this.model.setup;
646
+ for (let q = 0; q < a.sliderBones.length; q++) {
647
+ const tl = a.sliderBones[q], i = keyAt(tl.curve, this.cursors, tl.cursor, at);
648
+ if (i < 0) continue;
649
+ const o = tl.bone * LOCALS;
650
+ for (let f = 0; f < tl.fields.length; f++) {
651
+ const n = o + tl.fields[f], v = channelValue(tl.curve, i, f, at), current = L[n], setup = S[n];
652
+ if (tl.multiply[f]) {
653
+ if (r.additive) L[n] = current + (v * setup - setup) * alpha;
654
+ else {
655
+ const target = setup * v, from = Math.abs(current) * Math.sign(target);
656
+ L[n] = alpha === 1 ? target : from + (target - from) * alpha;
657
+ }
658
+ } else L[n] = r.additive ? current + v * alpha : current + (setup + v - current) * alpha;
659
+ }
660
+ }
661
+ for (let q = 0; q < c.bones.length; q++) this.changed[q] = c.bones[q];
662
+ if (c.bones.length > 0) this.repose(c.bones.length, 0);
663
+ }
664
+
665
+ private attachmentValues(anim: RuntimeAnimation | null, t: number): void {
666
+ this.slotValues(anim, t);
667
+ this.deformValues(anim, t);
668
+ for (let k = 0; k < this.model.constraints.length; k++) {
669
+ this.sliderSlots(k);
670
+ this.sliderDeforms(k);
671
+ }
672
+ }
673
+
674
+ private sliderSlots(index: number): void {
675
+ const cs = this.model.constraints;
676
+ {
677
+ const k = index, c = cs[k];
678
+ if (c.kind !== 'slider' || !c.applied || this.sliderMix[k] === 0) return;
679
+ const a = c.animation, t = this.sliderAt[k], alpha = this.sliderMix[k];
680
+ for (let q = 0; q < a.slots.length; q++) {
681
+ const tl = a.slots[q], i = keyAt(tl.curve, this.cursors, tl.cursor, t);
682
+ if (i < 0) continue;
683
+ if (tl.attachments !== null) {
684
+ const next = tl.attachments[i];
685
+ if (next !== this.shown[tl.slot] && next >= 0) this.deformed[next] = 0;
686
+ this.shown[tl.slot] = next;
687
+ }
688
+ else {
689
+ const channels = tl.channels!;
690
+ for (let f = 0; f < channels.length; f++) {
691
+ const n = tl.slot * 4 + channels[f], v = channelValue(tl.curve, i, f, t);
692
+ this.light[n] = clamp01(alpha === 1 ? v : this.light[n] + (v - this.light[n]) * alpha);
693
+ }
694
+ }
695
+ }
696
+ }
697
+ }
698
+
699
+ private sliderDeforms(index: number): void {
700
+ if (this.model.omitDeform) return;
701
+ const cs = this.model.constraints;
702
+ {
703
+ const k = index, c = cs[k];
704
+ if (c.kind !== 'slider' || !c.applied || this.sliderMix[k] === 0) return;
705
+ const a = c.animation, t = this.sliderAt[k], alpha = this.sliderMix[k];
706
+ for (let q = 0; q < a.deforms.length; q++) {
707
+ const tl = a.deforms[q], i = keyAt(tl.curve, this.cursors, tl.cursor, t);
708
+ if (i < 0) continue;
709
+ const held = i + 1 === tl.curve.keys || tl.curve.kinds[i] === CURVE_STEPPED;
710
+ const p = held ? 0 : deformFraction(tl.curve, i, t), start = i * tl.count;
711
+ for (let slot = 0; slot < this.shown.length; slot++) {
712
+ const index = this.shown[slot];
713
+ if (index < 0 || !this.model.slots[slot].live) continue;
714
+ const at = this.model.attachments[index];
715
+ if (at.timeline !== tl.identity || at.deformCount === 0) continue;
716
+ const current = this.deformed[index] !== 0;
717
+ for (let f = 0; f < tl.count; f++) {
718
+ const v = tl.held[start + f], target = held ? v : v + (tl.held[start + tl.count + f] - v) * p;
719
+ const setup = at.draw === DRAW_MESH ? at.local[f] : 0, n = at.deformOffset + f;
720
+ const from = current ? this.deform[n] : setup;
721
+ this.deform[n] = alpha === 1 && (!c.setup.additive || !current) ? target : c.setup.additive ? from + (target - setup) * alpha : from + (target - from) * alpha;
722
+ }
723
+ this.deformed[index] = 1;
724
+ }
725
+ }
445
726
  }
446
727
  }
447
728
 
@@ -868,6 +1149,20 @@ export class RuntimePose {
868
1149
  const wb = W[w + W_B];
869
1150
  const wc = W[w + W_C];
870
1151
  const wd = W[w + W_D];
1152
+ if (mode === MODE_NO_SCALE || mode === MODE_NO_SCALE_OR_REFLECTION) {
1153
+ const det = pa * pd - pb * pc;
1154
+ const flip = mode === MODE_NO_SCALE && det < 0 ? -1 : 1;
1155
+ const sign = det < 0 ? -1 : 1;
1156
+ const rotation = Math.atan2((pa * wc - pc * wa) * sign, (pd * wa - pb * wc) * sign) * DEG;
1157
+ const rr = rotation * RAD;
1158
+ const cos = Math.cos(rr), sin = Math.sin(rr);
1159
+ let ux = pa * cos + pb * sin, uy = pc * cos + pd * sin;
1160
+ const length = 1 / Math.sqrt(ux * ux + uy * uy);
1161
+ ux *= length; uy *= length;
1162
+ this.decompose(l, ux * wa + uy * wc, (ux * wc - uy * wa) * flip, ux * wb + uy * wd, (ux * wd - uy * wb) * flip, true);
1163
+ L[l + L_ROTATION] = rotation;
1164
+ return;
1165
+ }
871
1166
  if (mode === MODE_ONLY_TRANSLATION) {
872
1167
  this.decompose(l, wa, wc, wb, wd);
873
1168
  return;
@@ -887,7 +1182,7 @@ export class RuntimePose {
887
1182
  }
888
1183
 
889
1184
  /** A local matrix with columns `(la, lc)` and `(lb, ld)` as rotation, scales and shear (`decompose`). */
890
- private decompose(l: number, la: number, lc: number, lb: number, ld: number): void {
1185
+ private decompose(l: number, la: number, lc: number, lb: number, ld: number, shearX = false): void {
891
1186
  const L = this.locals;
892
1187
  const sx = Math.sqrt(la * la + lc * lc);
893
1188
  const yLength = Math.sqrt(lb * lb + ld * ld);
@@ -901,11 +1196,11 @@ export class RuntimePose {
901
1196
  const xAngle = Math.atan2(lc, la) * DEG;
902
1197
  const det = la * ld - lb * lc;
903
1198
  const yAngle = Math.atan2(ld, lb) * DEG;
904
- L[l + L_ROTATION] = xAngle;
1199
+ L[l + (shearX ? L_SHEAR_X : L_ROTATION)] = xAngle;
905
1200
  L[l + L_SCALE_X] = sx;
906
1201
  L[l + L_SCALE_Y] = det < 0 ? -yLength : yLength;
907
1202
  const right = det < 0 ? -90 : 90;
908
- let shear = yAngle - (xAngle + right);
1203
+ let shear = yAngle - ((shearX ? 0 : xAngle) + right);
909
1204
  shear = shear > 180 ? shear - 360 : shear < -180 ? shear + 360 : shear;
910
1205
  L[l + L_SHEAR_Y] = yLength > 0.00001 ? shear : 0;
911
1206
  }