spine-rigc 2.53.0 → 2.55.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.
@@ -38,6 +38,7 @@ import {
38
38
  import { spinePosingFor, type SpineSkeletonData } from './spine_side.ts';
39
39
  import { walkTimelines } from './timelines.ts';
40
40
  import { firstNonFinite, type PosedVertices, type WorldTransform } from './nonfinite.ts';
41
+ import { environmentAt, type EnvironmentChange, type EnvironmentStep } from './environment.ts';
41
42
 
42
43
  // ---------------------------------------------------------------------------
43
44
  // the Spine side, through the seam (issue #1052)
@@ -211,6 +212,16 @@ export interface FramesSidecar {
211
212
  slots?: string[];
212
213
  /** The slots these frames leave out, when `render --hide` named them — see `slots`. */
213
214
  hidden?: string[];
215
+ /**
216
+ * The schedule the physics answered, when `render --environment` gave one
217
+ * (issue #1416) — its steps as read, each `{ t, wind?, gravity? }`.
218
+ *
219
+ * ⭐ Absent on a render without it, for `skin`'s reason: every frame set
220
+ * written before the field existed says the same. A frame set carrying it is
221
+ * a picture of the rig in a world `check` does not pose, so `check` refuses
222
+ * it as a reference by name.
223
+ */
224
+ environment?: EnvironmentStep[];
214
225
  /** The colour the frames were cleared to, straight RGBA 0..255. */
215
226
  background: RGBA;
216
227
  viewport: {
@@ -391,6 +402,19 @@ export interface PoseOptions {
391
402
  * second render writes.
392
403
  */
393
404
  unclipped?: boolean;
405
+ /**
406
+ * The world the physics answers, in time — `render --environment` (issue
407
+ * #1416, `./environment.ts`): each step's change set before the step that
408
+ * poses the first frame at or after its `t`, on the sampler's own frame grid,
409
+ * on whichever poser draws. Absent, nothing is set and every pose is the one
410
+ * it always was.
411
+ *
412
+ * ⭐ **It is part of the shot, so the framing reads it.** Unlike `slots` and
413
+ * `hidden`, which say which pixels are drawn of one pose, an environment
414
+ * changes the pose — a gust can carry a coat past the box a still rig fills —
415
+ * so `framingViewport` keeps it and the box holds what the frames draw.
416
+ */
417
+ environment?: readonly EnvironmentStep[];
394
418
  }
395
419
 
396
420
  /**
@@ -1025,8 +1049,10 @@ export interface Poser {
1025
1049
  /**
1026
1050
  * Animation `name` under `skin`, not looping: `visit(i, posed)` for every
1027
1051
  * `i` from 0 to `count`, the pose at `i/fps`. The caller has checked `name`.
1052
+ * `environment(i)`, when given, is the change to the world the physics
1053
+ * answers set before the step that poses frame `i` (issue #1416).
1028
1054
  */
1029
- animation(name: string, skin: string | undefined, fps: number, count: number, visit: (index: number, posed: Posed) => void): void;
1055
+ animation(name: string, skin: string | undefined, fps: number, count: number, visit: (index: number, posed: Posed) => void, environment?: (index: number) => EnvironmentChange | undefined): void;
1030
1056
  /** The rest table for the (slot, attachment) pairs `shown` holds — see `AttachmentRest`. */
1031
1057
  rest(skin: string | undefined, shown: readonly AttachmentPose[][]): AttachmentRest[];
1032
1058
  }
@@ -1091,7 +1117,12 @@ export function sampleAnimation(source: PoseSource, name: string, fps: number, o
1091
1117
  const count = Math.round(animation.duration * fps);
1092
1118
  const draw = drawResolver(poser, opts);
1093
1119
  const frames: Frame[] = [];
1094
- poser.animation(name, opts?.skin, fps, count, (i, posed) => frames.push(frameOf(i, i * step, posed, draw(), opts)));
1120
+ const visit = (i: number, posed: Posed): void => {
1121
+ frames.push(frameOf(i, i * step, posed, draw(), opts));
1122
+ };
1123
+ // Without an environment the call is the one it always was, so a poser that takes none is asked nothing new.
1124
+ if (opts?.environment === undefined) poser.animation(name, opts?.skin, fps, count, visit);
1125
+ else poser.animation(name, opts.skin, fps, count, visit, environmentAt(opts.environment, fps));
1095
1126
  return frames;
1096
1127
  }
1097
1128
 
@@ -1411,6 +1442,8 @@ export interface GeometryFile {
1411
1442
  /** The animation, or `null` for a skeleton with none (its one frame is the setup pose). */
1412
1443
  animation: string | null;
1413
1444
  skin?: string;
1445
+ /** The schedule the physics answered (`render --environment`, issue #1416) — `frames.json`'s own `environment`; absent without it. */
1446
+ environment?: EnvironmentStep[];
1414
1447
  fps: number;
1415
1448
  /** The box the PNG frames beside this file were drawn over — `frames.json`'s own `viewport`. */
1416
1449
  viewport: FramesSidecar['viewport'];
@@ -1470,6 +1503,7 @@ export function geometryFileOf(
1470
1503
  frames: Frame[],
1471
1504
  viewport: Viewport,
1472
1505
  skin: string | undefined,
1506
+ environment?: EnvironmentStep[],
1473
1507
  ): GeometryFile {
1474
1508
  const geometryFrame = (frame: Frame, where: string): Omit<GeometryFrame, 'index' | 'time'> => {
1475
1509
  if (frame.bones === undefined || frame.attachments === undefined) {
@@ -1493,6 +1527,7 @@ export function geometryFileOf(
1493
1527
  coordinates: GEOMETRY_COORDINATES,
1494
1528
  animation,
1495
1529
  ...(skin === undefined ? {} : { skin }),
1530
+ ...(environment === undefined ? {} : { environment }),
1496
1531
  fps,
1497
1532
  viewport: sidecarViewport(viewport),
1498
1533
  bones: poser.bones.map(({ name, parent }) => ({ name, parent })),
package/src/timelines.ts CHANGED
@@ -476,7 +476,7 @@ export interface PhysicsPoseRule {
476
476
  * the value, and the row's `why` — the key's sentence — opens with the arms a
477
477
  * key can still take. Until this field existed `src/types.ts` said of all four
478
478
  * rows that "the bounds are the runtime's, not a policy", which is true of two
479
- * of the eight ways out.
479
+ * of the nine ways out.
480
480
  */
481
481
  basis: readonly PhysicsBoundBasis[];
482
482
  /** The bound in words, for a message: what the value has to be. */
@@ -517,7 +517,7 @@ const STRENGTH_OUTSIDE: readonly PhysicsOutsideArm[] = [
517
517
  ];
518
518
 
519
519
  /**
520
- * The eight ways out of the four bounds, each with its basis (issue #798). They
520
+ * The nine ways out of the four bounds, each with its basis (issue #798). They
521
521
  * are named constants rather than literals inside the rows because each row's
522
522
  * `why` quotes its own — the key's sentence and `A23`'s setup sentence are then
523
523
  * one text about one number, as `STRENGTH_OUTSIDE` already made them for two.
@@ -531,7 +531,8 @@ const STRENGTH_OUTSIDE: readonly PhysicsOutsideArm[] = [
531
531
  * exactly where the rig with no constraint does, on every step; `mix` −0.5 is
532
532
  * finite and moves the bone by exactly −1× what +0.5 moves it by; `damping` 2 is
533
533
  * finite over the walk (Infinity only at step 1,065); `damping` −0.5 at 45 fps is
534
- * NaN at step 2. `T113` re-takes all eight on every run.
534
+ * NaN at step 2. The ninth, `damping` 1 (issue #1422), is finite on every step
535
+ * and its velocity never decays. `T113` re-takes all nine on every run.
535
536
  */
536
537
  const MIX_BELOW_0: PhysicsBoundBasis = {
537
538
  kind: 'behavioural',
@@ -584,6 +585,33 @@ const DAMPING_ABOVE_1: PhysicsBoundBasis = {
584
585
  witness: { value: 2 },
585
586
  does: 'above 1 every velocity grows on every step and the offset diverges',
586
587
  };
588
+ /**
589
+ * `damping` at exactly 1 (issue #1422) — inside the bound from #794 until then.
590
+ *
591
+ * 🔑 The decay `damping ** (60 * step)` is 1 at every rate, so the step map
592
+ * `physicsStepStability` derives has determinant 1, and for every `k` under the
593
+ * bound its two eigenvalues are a complex pair of modulus √1 = 1: ON the unit
594
+ * circle, where every other damping puts them inside it. Nothing an excitation
595
+ * puts into the offset ever decays, so the rig is marginal at best — and the
596
+ * excitation is not benign. [measured on issue #1422, spine-core and rigc's core
597
+ * identical bit for bit, x drive, inertia 1, fps 30, strength 100, the runtime's
598
+ * default world, 600 s at 60 Hz] one such link held its peak offset at 40.3 for
599
+ * the whole walk; two identical ones on one chain grew it LINEARLY, 6.03e3 at
600
+ * 60 s to 6.09e4 at 600 s; three ran past 10⁶ at 316 s. The parent's undamped
601
+ * oscillation reaches the child through inertia at exactly the child's own
602
+ * frequency. Detuning the second link's strength by 1 % held it flat, which is
603
+ * why the refusal is of the value and not of a pair: equal maps are the edge,
604
+ * and no authored number states how close is too close.
605
+ */
606
+ const DAMPING_AT_1: PhysicsBoundBasis = {
607
+ kind: 'behavioural',
608
+ when: (v) => v === 1,
609
+ witness: { value: 1 },
610
+ does:
611
+ 'at 1 the per-step decay is 1 at every rate, so no velocity ever decays: the step\'s two eigenvalues lie ON the unit ' +
612
+ 'circle (modulus √1 = 1) rather than inside it, so nothing a push puts in (wind, gravity, a key, the bone moving) ever ' +
613
+ 'dies out, and two such constraints on one chain stepping alike resonate and grow without limit',
614
+ };
587
615
  const DAMPING_BELOW_0: PhysicsBoundBasis = {
588
616
  kind: 'arithmetic',
589
617
  when: (v) => v < 0,
@@ -626,8 +654,8 @@ const DAMPING_BELOW_0: PhysicsBoundBasis = {
626
654
  *
627
655
  * 🔎 Each row's `basis` says, per way out, whether the runtime's arithmetic
628
656
  * fails there or the value runs and rigc refuses what it does (issue #798). Of
629
- * the eight ways out, two are arithmetic — `mass` at 0 and `damping` below 0 —
630
- * and six are rigc's call.
657
+ * the nine ways out, two are arithmetic — `mass` at 0 and `damping` below 0 —
658
+ * and seven are rigc's call.
631
659
  */
632
660
  export const PHYSICS_POSE_RULES: PhysicsPoseRule[] = [
633
661
  {
@@ -697,7 +725,7 @@ export const PHYSICS_POSE_RULES: PhysicsPoseRule[] = [
697
725
  timeline: 'damping',
698
726
  field: 'damping',
699
727
  toPose: (v) => v,
700
- poseOk: (v) => v >= 0 && v <= 1,
728
+ poseOk: (v) => v >= 0 && v < 1,
701
729
  keyOk: null,
702
730
  inertAtSetup: false,
703
731
  outside: null,
@@ -705,22 +733,27 @@ export const PHYSICS_POSE_RULES: PhysicsPoseRule[] = [
705
733
  // above 1 is a run-away, finite until the velocity overflows (step 1,065
706
734
  // for a setup of 2 at 60 fps), so it is behavioural by the line
707
735
  // `PhysicsBoundBasis` draws — the card that asked for the bases (#798)
708
- // counted it arithmetic, and the walk says otherwise.
709
- basis: [DAMPING_ABOVE_1, DAMPING_BELOW_0],
710
- states: 'inside [0, 1]',
711
- statesKeyed: 'inside [0, 1]',
712
- // 🔑 The interval is CLOSED (issue #794). `1 ** x` is 1 and `0 ** x` is 0
713
- // for every positive exponent, so both ends are finite at every `fps` and
714
- // both are rigs somebody can mean: 1 holds the jiggle, 0 follows the bone
715
- // with no overshoot. [measured] through spine-core on the generated physics
716
- // fixture, 120 steps from `Physics.reset` at 60, 45 and 30 fps under a
717
- // displacing animation, as a setup value and as a key: every pose value
718
- // finite, the velocity never 0 at 1 and 0 after every step at 0. And 1 is
719
- // not only a choice: it is 4.2's own parser default for an omitted
736
+ // counted it arithmetic, and the walk says otherwise. 1 itself is
737
+ // behavioural too: finite on every step, and never decaying.
738
+ basis: [DAMPING_ABOVE_1, DAMPING_AT_1, DAMPING_BELOW_0],
739
+ states: 'inside [0, 1)',
740
+ statesKeyed: 'inside [0, 1)',
741
+ // 🔑 The interval is HALF-OPEN (issue #1422): 0 is in and 1 is out. `0 ** x`
742
+ // is 0 for every positive exponent, so 0 follows the bone with no overshoot
743
+ // at every `fps` — a rig somebody can mean. 1 was inside from #794 to #1422,
744
+ // on the argument that `1 ** x` is 1 and finite at every rate, a jiggle held
745
+ // rather than a fault. Finite it is — [measured] through spine-core on the
746
+ // generated physics fixture, 120 steps from `Physics.reset` at 60, 45 and
747
+ // 30 fps, every pose value finite and the velocity never 0 — and that is why
748
+ // its arm is behavioural. What the argument missed is that a step whose
749
+ // decay is 1 has its eigenvalues on the unit circle, so nothing that excites
750
+ // it ever dies out, and a chain of such links resonates: `DAMPING_AT_1` has
751
+ // the measurement. ⚠️ 1 is also 4.2's own parser default for an omitted
720
752
  // `damping` (`SkeletonJson.js:242` in 4.2.120,
721
753
  // `getValue(constraintMap, "damping", 1)`; 4.3.13 reads 0.85 at `:308`), so a
722
- // rig migrated from 4.2 that keys "the default" writes 1 and the editor
723
- // exports it as it is. The open interval this replaced refused that rig.
754
+ // rig migrated from 4.2 that keys "the default" writes 1 and is refused; at
755
+ // #1422 none of the public recipes, the gallery, the twelve example exports,
756
+ // the trailer rig or the parts recipes states it, at rest or keyed.
724
757
  //
725
758
  // ⚠️ The frame-rate half is why the bound cannot be checked by playing a rig
726
759
  // at 60 fps (issue #748): `step` is `1 / fps`, the constraint's own rate, so
@@ -734,12 +767,86 @@ export const PHYSICS_POSE_RULES: PhysicsPoseRule[] = [
734
767
  "every rate — finite on every step until the velocity overflows, so refusing it is rigc's call rather than the " +
735
768
  "runtime's. Below 0 the result depends on `fps`: where `60 / fps` is a " +
736
769
  'whole number a negative base stays finite — at 60 fps it is the velocity\'s sign flipped each step, which can ' +
737
- `look like a jiggle settling — and ${DAMPING_BELOW_0.expression} (\`(-0.5) ** (60 / 45)\`), so a rig tried only at 60 fps never shows the failure. The two ends are ` +
738
- 'values the runtime plays at every rate: at 1 the velocity never decays, so the jiggle holds for as long as it ' +
739
- 'runs, and at 0 every velocity is zeroed on every step, so the offset follows the bone with no overshoot',
770
+ `look like a jiggle settling — and ${DAMPING_BELOW_0.expression} (\`(-0.5) ** (60 / 45)\`), so a rig tried only at 60 fps never shows the failure. ` +
771
+ `And ${DAMPING_AT_1.does} — finite on every step as well, so that refusal is rigc's call too. 0 is inside: ` +
772
+ 'at 0 every velocity is zeroed on every step, so the offset follows the bone with no overshoot',
740
773
  },
741
774
  ];
742
775
 
776
+ /** What `physicsStepStability` reads off one pose of a physics constraint. */
777
+ export interface PhysicsStepStability {
778
+ /** `strength · massInverse · step²`, as the step's own products form it: `strength * (step * massInverse) * step`. */
779
+ readonly k: number;
780
+ /** The per-step velocity decay, `damping ** (60 * step)` — the runtime's own expression. */
781
+ readonly d: number;
782
+ /** `2 · (1 + d)`: for `d` below 1 the step is stable exactly when `k` is below it. */
783
+ readonly bound: number;
784
+ /** The largest magnitude among the step map's two eigenvalues — what one step multiplies an undisturbed offset by, at most. */
785
+ readonly growth: number;
786
+ /** `k < bound` and `d < 1`, both strictly. */
787
+ readonly stable: boolean;
788
+ }
789
+
790
+ /**
791
+ * Whether one pose of a physics constraint integrates to rest or runs away
792
+ * (issue #1416, part B) — a joint condition on three of the four values
793
+ * `PHYSICS_POSE_RULES` bounds one at a time, and on the constraint's `step`.
794
+ *
795
+ * 🔑 Every substep of `stepPhysics` (`src/core/constraints_physics.ts`, held to
796
+ * spine-core's `PhysicsConstraint.update` at tolerance 0) is, on each driven
797
+ * component, `v += (force − offset·strength)·m; offset += v·step; v *= d` with
798
+ * `m = step · massInverse` and `d = damping ** (60·step)`. The force is
799
+ * additive — wind and gravity set how far the offset goes, never whether it
800
+ * comes back — so with `u = v·step` the step is the linear map
801
+ * `[offset; u] ← [[1 − k, 1], [−k·d, d]]·[offset; u]`, `k = strength·m·step`:
802
+ * trace `1 − k + d`, determinant `d`. For `d` in `[0, 1)` both eigenvalues lie
803
+ * inside the unit circle exactly when `0 < k < 2·(1 + d)`. The lower end is the
804
+ * strength row's; this is the upper.
805
+ *
806
+ * ⚠️ **Not at `d = 1`** — this comment said `[0, 1]` until issue #1422, and it
807
+ * was false at that end. The determinant is `d`, so at `d = 1` the product of the
808
+ * two eigenvalues is 1, and for every `0 < k < 4` they are a complex pair of
809
+ * modulus `√d` = 1: on the circle, not inside it. One such link never decays
810
+ * (marginal), and two on one chain stepping alike resonate and grow without
811
+ * limit — measured on #1422, and the reason the damping row now ends below 1
812
+ * (`DAMPING_AT_1`). `d` is 1 for a `damping` of 1 at every rate, and — in the
813
+ * runtime's own doubles — for a `damping` a hair under 1 at a high enough rate:
814
+ * [measured] the largest double below 1 gives `d` exactly 1 from fps 121 on,
815
+ * while the largest float32 below 1, which is all a key can hold, needs about
816
+ * 6.4·10¹⁰. The runtime then multiplies every velocity by exactly 1, so `stable`
817
+ * requires `d < 1` as well, and A23 names that case by the decay it reached.
818
+ *
819
+ * ⚠️ **Strict.** At `k = 2·(1 + d)` one eigenvalue is exactly −1: the offset
820
+ * flips sign every step and never settles, and at `d = 1` the two coincide and
821
+ * it grows linearly.
822
+ *
823
+ * 📏 [measured, issue #1416's comment of 2026-10-14] on 270 generated rigs —
824
+ * x, y, rotate, scaleX and shearX drives; strength 10/100/1000; mass 0.1/1; fps
825
+ * 5/15/60; damping 0/0.85/1 — 115 of 115 with `k > bound` ran past 10⁶ units
826
+ * under a unit gust along the drive (92 of them in the runtime's default world
827
+ * with nothing set), 145 of 145 with `k < bound` stayed bounded, core and
828
+ * spine-core identical bit for bit. The 10 exactly on it (damping 1, k = 4)
829
+ * stayed under 10⁶ in the 6 s walk, which is why the 6 s classifier cannot
830
+ * stand for this edge: the x, y and scaleX ones ended the walk at their peak,
831
+ * still growing, and rotate and shearX swung their tip the bone's full length.
832
+ * All 270 passed every gate before this, because each value was inside its
833
+ * own row.
834
+ *
835
+ * The products are formed in the runtime's own order (`m = t * massInverse`,
836
+ * `d = damping ** (60 * t)`), so the number printed is the runtime's `k`
837
+ * rather than a re-association of it.
838
+ */
839
+ export function physicsStepStability(strength: number, massInverse: number, damping: number, step: number): PhysicsStepStability {
840
+ const m = step * massInverse;
841
+ const k = strength * m * step;
842
+ const d = damping ** (60 * step);
843
+ const bound = 2 * (1 + d);
844
+ const trace = 1 - k + d;
845
+ const disc = trace * trace - 4 * d;
846
+ const growth = disc >= 0 ? Math.max(Math.abs(trace + Math.sqrt(disc)), Math.abs(trace - Math.sqrt(disc))) / 2 : Math.sqrt(d);
847
+ return { k, d, bound, growth, stable: k < bound && d < 1 };
848
+ }
849
+
743
850
  /**
744
851
  * What `A23`'s setup arm says about a pose value its rule refuses: the arm of
745
852
  * `outside` that holds for it, or the bound itself where the row has no arms or
package/src/types.ts CHANGED
@@ -352,14 +352,15 @@ export type BoneProperty =
352
352
  * export; `PHYSICS_TRACKS` in `compile.ts` carries the argument.
353
353
  *
354
354
  * 🔒 **Four of them have a compile-time range** (issue #610): `mass` must be
355
- * `> 0`, `damping` inside the closed `[0, 1]`, and `mix` and `strength` `0` or
356
- * more. The bounds are `PHYSICS_POSE_RULES` in `src/timelines.ts`, and each row's
355
+ * `> 0`, `damping` inside the half-open `[0, 1)` (issue #1422; closed from #794),
356
+ * and `mix` and `strength` `0` or more. The bounds are `PHYSICS_POSE_RULES` in `src/timelines.ts`, and each row's
357
357
  * `basis` says, per way out, whose they are (issue #798). Two are the
358
358
  * runtime's arithmetic: a `mass` of 0 is an infinite `massInverse` and NaN from
359
359
  * the first step, and a `damping` below 0 is a negative base under a fractional
360
- * exponent at any `fps` where `60 / fps` is not whole. The other six are rigc's
360
+ * exponent at any `fps` where `60 / fps` is not whole. The other seven are rigc's
361
361
  * call — the rig runs, finitely, and runs wrongly: `mass` below 0 and
362
362
  * `strength` below 0 run away, `damping` above 1 diverges until it overflows,
363
+ * `damping` at 1 never decays and resonates on a chain,
363
364
  * `mix` below 0 is the jiggle inverted, and a setup `mix` or `strength` of 0 is
364
365
  * a constraint muted or pulled back by nothing. `inertia`, `wind`, `gravity`
365
366
  * and the top of `mix` are bounded nowhere, because the runtime documents