@flighthq/spring 0.1.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.
Files changed (40) hide show
  1. package/dist/createSpring.d.ts +3 -0
  2. package/dist/createSpring.d.ts.map +1 -0
  3. package/dist/createSpring.js +7 -0
  4. package/dist/createSpring.js.map +1 -0
  5. package/dist/index.d.ts +8 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +8 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/isSpringSettled.d.ts +3 -0
  10. package/dist/isSpringSettled.d.ts.map +1 -0
  11. package/dist/isSpringSettled.js +15 -0
  12. package/dist/isSpringSettled.js.map +1 -0
  13. package/dist/resetSpring.d.ts +3 -0
  14. package/dist/resetSpring.d.ts.map +1 -0
  15. package/dist/resetSpring.js +8 -0
  16. package/dist/resetSpring.js.map +1 -0
  17. package/dist/spring2D.d.ts +5 -0
  18. package/dist/spring2D.d.ts.map +1 -0
  19. package/dist/spring2D.js +22 -0
  20. package/dist/spring2D.js.map +1 -0
  21. package/dist/spring3D.d.ts +5 -0
  22. package/dist/spring3D.d.ts.map +1 -0
  23. package/dist/spring3D.js +27 -0
  24. package/dist/spring3D.js.map +1 -0
  25. package/dist/springConfig.d.ts +4 -0
  26. package/dist/springConfig.d.ts.map +1 -0
  27. package/dist/springConfig.js +18 -0
  28. package/dist/springConfig.js.map +1 -0
  29. package/dist/updateSpring.d.ts +3 -0
  30. package/dist/updateSpring.d.ts.map +1 -0
  31. package/dist/updateSpring.js +81 -0
  32. package/dist/updateSpring.js.map +1 -0
  33. package/package.json +38 -0
  34. package/src/createSpring.test.ts +17 -0
  35. package/src/isSpringSettled.test.ts +39 -0
  36. package/src/resetSpring.test.ts +20 -0
  37. package/src/spring2D.test.ts +50 -0
  38. package/src/spring3D.test.ts +47 -0
  39. package/src/springConfig.test.ts +27 -0
  40. package/src/updateSpring.test.ts +138 -0
@@ -0,0 +1,3 @@
1
+ import type { Spring } from '@flighthq/types';
2
+ export declare function createSpring(value?: number, velocity?: number): Spring;
3
+ //# sourceMappingURL=createSpring.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"createSpring.d.ts","sourceRoot":"","sources":["../src/createSpring.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAK9C,wBAAgB,YAAY,CAAC,KAAK,GAAE,MAAU,EAAE,QAAQ,GAAE,MAAU,GAAG,MAAM,CAE5E"}
@@ -0,0 +1,7 @@
1
+ // Allocate a scalar spring at `value` (default 0) with `velocity` (default 0). This is the only
2
+ // allocating function for scalar springs; `updateSpring`, `resetSpring`, and the settle query all
3
+ // write into or read an existing spring.
4
+ export function createSpring(value = 0, velocity = 0) {
5
+ return { value, velocity };
6
+ }
7
+ //# sourceMappingURL=createSpring.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"createSpring.js","sourceRoot":"","sources":["../src/createSpring.ts"],"names":[],"mappings":"AAEA,gGAAgG;AAChG,kGAAkG;AAClG,yCAAyC;AACzC,MAAM,UAAU,YAAY,CAAC,QAAgB,CAAC,EAAE,WAAmB,CAAC;IAClE,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC;AAC7B,CAAC"}
@@ -0,0 +1,8 @@
1
+ export * from './createSpring';
2
+ export * from './isSpringSettled';
3
+ export * from './resetSpring';
4
+ export * from './spring2D';
5
+ export * from './spring3D';
6
+ export * from './springConfig';
7
+ export * from './updateSpring';
8
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,mBAAmB,CAAC;AAClC,cAAc,eAAe,CAAC;AAC9B,cAAc,YAAY,CAAC;AAC3B,cAAc,YAAY,CAAC;AAC3B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,gBAAgB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export * from './createSpring';
2
+ export * from './isSpringSettled';
3
+ export * from './resetSpring';
4
+ export * from './spring2D';
5
+ export * from './spring3D';
6
+ export * from './springConfig';
7
+ export * from './updateSpring';
8
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,mBAAmB,CAAC;AAClC,cAAc,eAAe,CAAC;AAC9B,cAAc,YAAY,CAAC;AAC3B,cAAc,YAAY,CAAC;AAC3B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,gBAAgB,CAAC"}
@@ -0,0 +1,3 @@
1
+ import type { Spring } from '@flighthq/types';
2
+ export declare function isSpringSettled(spring: Readonly<Spring>, target: number, positionEpsilon?: number, velocityEpsilon?: number): boolean;
3
+ //# sourceMappingURL=isSpringSettled.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"isSpringSettled.d.ts","sourceRoot":"","sources":["../src/isSpringSettled.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAS9C,wBAAgB,eAAe,CAC7B,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,EACxB,MAAM,EAAE,MAAM,EACd,eAAe,GAAE,MAA8B,EAC/C,eAAe,GAAE,MAA8B,GAC9C,OAAO,CAET"}
@@ -0,0 +1,15 @@
1
+ import { approxEqual, approxZero } from '@flighthq/math';
2
+ // Report whether `spring` has come to rest at `target`: its `value` is within `positionEpsilon` of
3
+ // the target AND its `velocity` is within `velocityEpsilon` of zero. Both conditions are required —
4
+ // a spring passing through the target at speed (an underdamped overshoot) is not settled.
5
+ //
6
+ // Settle is a property of position and velocity alone, independent of the `SpringConfig` that drove
7
+ // the motion, so no config is taken. Epsilons default to a UI-scale tolerance; pass tighter or
8
+ // looser values for the value's actual units.
9
+ export function isSpringSettled(spring, target, positionEpsilon = SPRING_SETTLE_EPSILON, velocityEpsilon = SPRING_SETTLE_EPSILON) {
10
+ return approxEqual(spring.value, target, positionEpsilon) && approxZero(spring.velocity, velocityEpsilon);
11
+ }
12
+ // Default position/velocity tolerance for settle detection: fine enough that motion has visually
13
+ // stopped for typical pixel/normalized values, loose enough not to wait on floating-point tails.
14
+ const SPRING_SETTLE_EPSILON = 1e-3;
15
+ //# sourceMappingURL=isSpringSettled.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"isSpringSettled.js","sourceRoot":"","sources":["../src/isSpringSettled.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAGzD,mGAAmG;AACnG,oGAAoG;AACpG,0FAA0F;AAC1F,EAAE;AACF,oGAAoG;AACpG,+FAA+F;AAC/F,8CAA8C;AAC9C,MAAM,UAAU,eAAe,CAC7B,MAAwB,EACxB,MAAc,EACd,kBAA0B,qBAAqB,EAC/C,kBAA0B,qBAAqB;IAE/C,OAAO,WAAW,CAAC,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,eAAe,CAAC,IAAI,UAAU,CAAC,MAAM,CAAC,QAAQ,EAAE,eAAe,CAAC,CAAC;AAC5G,CAAC;AAED,iGAAiG;AACjG,iGAAiG;AACjG,MAAM,qBAAqB,GAAG,IAAI,CAAC"}
@@ -0,0 +1,3 @@
1
+ import type { Spring } from '@flighthq/types';
2
+ export declare function resetSpring(spring: Spring, value: number, velocity?: number): void;
3
+ //# sourceMappingURL=resetSpring.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resetSpring.d.ts","sourceRoot":"","sources":["../src/resetSpring.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAK9C,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,GAAE,MAAU,GAAG,IAAI,CAGrF"}
@@ -0,0 +1,8 @@
1
+ // Snap `spring` to `value` with `velocity` (default 0), discarding its current motion. Use this to
2
+ // teleport a spring to a new state — e.g. initializing it at a target, or cutting a running spring
3
+ // dead. Unlike `updateSpring`, this applies no dynamics; the spring simply becomes what is passed.
4
+ export function resetSpring(spring, value, velocity = 0) {
5
+ spring.value = value;
6
+ spring.velocity = velocity;
7
+ }
8
+ //# sourceMappingURL=resetSpring.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resetSpring.js","sourceRoot":"","sources":["../src/resetSpring.ts"],"names":[],"mappings":"AAEA,mGAAmG;AACnG,mGAAmG;AACnG,mGAAmG;AACnG,MAAM,UAAU,WAAW,CAAC,MAAc,EAAE,KAAa,EAAE,WAAmB,CAAC;IAC7E,MAAM,CAAC,KAAK,GAAG,KAAK,CAAC;IACrB,MAAM,CAAC,QAAQ,GAAG,QAAQ,CAAC;AAC7B,CAAC"}
@@ -0,0 +1,5 @@
1
+ import type { Spring2D, SpringConfig } from '@flighthq/types';
2
+ export declare function createSpring2D(valueX?: number, valueY?: number, velocityX?: number, velocityY?: number): Spring2D;
3
+ export declare function isSpring2DSettled(spring2D: Readonly<Spring2D>, targetX: number, targetY: number, positionEpsilon?: number, velocityEpsilon?: number): boolean;
4
+ export declare function updateSpring2D(spring2D: Spring2D, targetX: number, targetY: number, config: Readonly<SpringConfig>, deltaTime: number): void;
5
+ //# sourceMappingURL=spring2D.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spring2D.d.ts","sourceRoot":"","sources":["../src/spring2D.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAQ9D,wBAAgB,cAAc,CAC5B,MAAM,GAAE,MAAU,EAClB,MAAM,GAAE,MAAU,EAClB,SAAS,GAAE,MAAU,EACrB,SAAS,GAAE,MAAU,GACpB,QAAQ,CAEV;AAID,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,QAAQ,CAAC,QAAQ,CAAC,EAC5B,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,eAAe,CAAC,EAAE,MAAM,EACxB,eAAe,CAAC,EAAE,MAAM,GACvB,OAAO,CAKT;AAKD,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,QAAQ,CAAC,YAAY,CAAC,EAC9B,SAAS,EAAE,MAAM,GAChB,IAAI,CAGN"}
@@ -0,0 +1,22 @@
1
+ import { createSpring } from './createSpring';
2
+ import { isSpringSettled } from './isSpringSettled';
3
+ import { updateSpring } from './updateSpring';
4
+ // Allocate a 2D spring as a pair of scalar springs, each at its `value*` (default 0) and `velocity*`
5
+ // (default 0).
6
+ export function createSpring2D(valueX = 0, valueY = 0, velocityX = 0, velocityY = 0) {
7
+ return { x: createSpring(valueX, velocityX), y: createSpring(valueY, velocityY) };
8
+ }
9
+ // Report whether both axes of `spring2D` have settled at (`targetX`, `targetY`) — the per-component
10
+ // `isSpringSettled` on each axis, combined with AND. Epsilons apply to each axis independently.
11
+ export function isSpring2DSettled(spring2D, targetX, targetY, positionEpsilon, velocityEpsilon) {
12
+ return (isSpringSettled(spring2D.x, targetX, positionEpsilon, velocityEpsilon) &&
13
+ isSpringSettled(spring2D.y, targetY, positionEpsilon, velocityEpsilon));
14
+ }
15
+ // Advance both axes of `spring2D` one `deltaTime` step toward (`targetX`, `targetY`) under the same
16
+ // `config`, applying the scalar `updateSpring` per component. The axes are independent; sharing one
17
+ // config just gives them the same response.
18
+ export function updateSpring2D(spring2D, targetX, targetY, config, deltaTime) {
19
+ updateSpring(spring2D.x, targetX, config, deltaTime);
20
+ updateSpring(spring2D.y, targetY, config, deltaTime);
21
+ }
22
+ //# sourceMappingURL=spring2D.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spring2D.js","sourceRoot":"","sources":["../src/spring2D.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAE9C,qGAAqG;AACrG,eAAe;AACf,MAAM,UAAU,cAAc,CAC5B,SAAiB,CAAC,EAClB,SAAiB,CAAC,EAClB,YAAoB,CAAC,EACrB,YAAoB,CAAC;IAErB,OAAO,EAAE,CAAC,EAAE,YAAY,CAAC,MAAM,EAAE,SAAS,CAAC,EAAE,CAAC,EAAE,YAAY,CAAC,MAAM,EAAE,SAAS,CAAC,EAAE,CAAC;AACpF,CAAC;AAED,oGAAoG;AACpG,gGAAgG;AAChG,MAAM,UAAU,iBAAiB,CAC/B,QAA4B,EAC5B,OAAe,EACf,OAAe,EACf,eAAwB,EACxB,eAAwB;IAExB,OAAO,CACL,eAAe,CAAC,QAAQ,CAAC,CAAC,EAAE,OAAO,EAAE,eAAe,EAAE,eAAe,CAAC;QACtE,eAAe,CAAC,QAAQ,CAAC,CAAC,EAAE,OAAO,EAAE,eAAe,EAAE,eAAe,CAAC,CACvE,CAAC;AACJ,CAAC;AAED,oGAAoG;AACpG,oGAAoG;AACpG,4CAA4C;AAC5C,MAAM,UAAU,cAAc,CAC5B,QAAkB,EAClB,OAAe,EACf,OAAe,EACf,MAA8B,EAC9B,SAAiB;IAEjB,YAAY,CAAC,QAAQ,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC;IACrD,YAAY,CAAC,QAAQ,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC;AACvD,CAAC"}
@@ -0,0 +1,5 @@
1
+ import type { Spring3D, SpringConfig } from '@flighthq/types';
2
+ export declare function createSpring3D(valueX?: number, valueY?: number, valueZ?: number, velocityX?: number, velocityY?: number, velocityZ?: number): Spring3D;
3
+ export declare function isSpring3DSettled(spring3D: Readonly<Spring3D>, targetX: number, targetY: number, targetZ: number, positionEpsilon?: number, velocityEpsilon?: number): boolean;
4
+ export declare function updateSpring3D(spring3D: Spring3D, targetX: number, targetY: number, targetZ: number, config: Readonly<SpringConfig>, deltaTime: number): void;
5
+ //# sourceMappingURL=spring3D.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spring3D.d.ts","sourceRoot":"","sources":["../src/spring3D.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAQ9D,wBAAgB,cAAc,CAC5B,MAAM,GAAE,MAAU,EAClB,MAAM,GAAE,MAAU,EAClB,MAAM,GAAE,MAAU,EAClB,SAAS,GAAE,MAAU,EACrB,SAAS,GAAE,MAAU,EACrB,SAAS,GAAE,MAAU,GACpB,QAAQ,CAMV;AAID,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,QAAQ,CAAC,QAAQ,CAAC,EAC5B,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,eAAe,CAAC,EAAE,MAAM,EACxB,eAAe,CAAC,EAAE,MAAM,GACvB,OAAO,CAMT;AAID,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,QAAQ,CAAC,YAAY,CAAC,EAC9B,SAAS,EAAE,MAAM,GAChB,IAAI,CAIN"}
@@ -0,0 +1,27 @@
1
+ import { createSpring } from './createSpring';
2
+ import { isSpringSettled } from './isSpringSettled';
3
+ import { updateSpring } from './updateSpring';
4
+ // Allocate a 3D spring as three scalar springs, each at its `value*` (default 0) and `velocity*`
5
+ // (default 0).
6
+ export function createSpring3D(valueX = 0, valueY = 0, valueZ = 0, velocityX = 0, velocityY = 0, velocityZ = 0) {
7
+ return {
8
+ x: createSpring(valueX, velocityX),
9
+ y: createSpring(valueY, velocityY),
10
+ z: createSpring(valueZ, velocityZ),
11
+ };
12
+ }
13
+ // Report whether all three axes of `spring3D` have settled at (`targetX`, `targetY`, `targetZ`) —
14
+ // the per-component `isSpringSettled` on each axis, combined with AND.
15
+ export function isSpring3DSettled(spring3D, targetX, targetY, targetZ, positionEpsilon, velocityEpsilon) {
16
+ return (isSpringSettled(spring3D.x, targetX, positionEpsilon, velocityEpsilon) &&
17
+ isSpringSettled(spring3D.y, targetY, positionEpsilon, velocityEpsilon) &&
18
+ isSpringSettled(spring3D.z, targetZ, positionEpsilon, velocityEpsilon));
19
+ }
20
+ // Advance all three axes of `spring3D` one `deltaTime` step toward (`targetX`, `targetY`, `targetZ`)
21
+ // under the same `config`, applying the scalar `updateSpring` per component.
22
+ export function updateSpring3D(spring3D, targetX, targetY, targetZ, config, deltaTime) {
23
+ updateSpring(spring3D.x, targetX, config, deltaTime);
24
+ updateSpring(spring3D.y, targetY, config, deltaTime);
25
+ updateSpring(spring3D.z, targetZ, config, deltaTime);
26
+ }
27
+ //# sourceMappingURL=spring3D.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spring3D.js","sourceRoot":"","sources":["../src/spring3D.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAE9C,iGAAiG;AACjG,eAAe;AACf,MAAM,UAAU,cAAc,CAC5B,SAAiB,CAAC,EAClB,SAAiB,CAAC,EAClB,SAAiB,CAAC,EAClB,YAAoB,CAAC,EACrB,YAAoB,CAAC,EACrB,YAAoB,CAAC;IAErB,OAAO;QACL,CAAC,EAAE,YAAY,CAAC,MAAM,EAAE,SAAS,CAAC;QAClC,CAAC,EAAE,YAAY,CAAC,MAAM,EAAE,SAAS,CAAC;QAClC,CAAC,EAAE,YAAY,CAAC,MAAM,EAAE,SAAS,CAAC;KACnC,CAAC;AACJ,CAAC;AAED,kGAAkG;AAClG,uEAAuE;AACvE,MAAM,UAAU,iBAAiB,CAC/B,QAA4B,EAC5B,OAAe,EACf,OAAe,EACf,OAAe,EACf,eAAwB,EACxB,eAAwB;IAExB,OAAO,CACL,eAAe,CAAC,QAAQ,CAAC,CAAC,EAAE,OAAO,EAAE,eAAe,EAAE,eAAe,CAAC;QACtE,eAAe,CAAC,QAAQ,CAAC,CAAC,EAAE,OAAO,EAAE,eAAe,EAAE,eAAe,CAAC;QACtE,eAAe,CAAC,QAAQ,CAAC,CAAC,EAAE,OAAO,EAAE,eAAe,EAAE,eAAe,CAAC,CACvE,CAAC;AACJ,CAAC;AAED,qGAAqG;AACrG,6EAA6E;AAC7E,MAAM,UAAU,cAAc,CAC5B,QAAkB,EAClB,OAAe,EACf,OAAe,EACf,OAAe,EACf,MAA8B,EAC9B,SAAiB;IAEjB,YAAY,CAAC,QAAQ,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC;IACrD,YAAY,CAAC,QAAQ,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC;IACrD,YAAY,CAAC,QAAQ,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC;AACvD,CAAC"}
@@ -0,0 +1,4 @@
1
+ import type { SpringConfig } from '@flighthq/types';
2
+ export declare function createSpringConfig(frequency: number, dampingRatio: number): SpringConfig;
3
+ export declare function createSpringConfigFromPhysical(stiffness: number, damping: number, mass: number): SpringConfig;
4
+ //# sourceMappingURL=springConfig.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"springConfig.d.ts","sourceRoot":"","sources":["../src/springConfig.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAKpD,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,YAAY,CAExF;AAMD,wBAAgB,8BAA8B,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,YAAY,CAK7G"}
@@ -0,0 +1,18 @@
1
+ import { TAU } from '@flighthq/math';
2
+ // Allocate a `SpringConfig` from the designer-intuitive `frequency` (Hz) and `dampingRatio`
3
+ // (0 undamped, <1 underdamped/overshoots, 1 critical/fastest-no-overshoot, >1 overdamped). This is
4
+ // the primary constructor; use `createSpringConfigFromPhysical` when starting from raw physics.
5
+ export function createSpringConfig(frequency, dampingRatio) {
6
+ return { dampingRatio, frequency };
7
+ }
8
+ // Allocate a `SpringConfig` from a physical spring: `stiffness` (k), `damping` (c), and `mass` (m).
9
+ // Converts to the mass-independent form via `frequency = sqrt(k / m) / (2 * PI)` and
10
+ // `dampingRatio = c / (2 * sqrt(k * m))`, the standard undamped-natural-frequency and
11
+ // damping-ratio identities. Expects positive `stiffness` and `mass`.
12
+ export function createSpringConfigFromPhysical(stiffness, damping, mass) {
13
+ return {
14
+ dampingRatio: damping / (2 * Math.sqrt(stiffness * mass)),
15
+ frequency: Math.sqrt(stiffness / mass) / TAU,
16
+ };
17
+ }
18
+ //# sourceMappingURL=springConfig.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"springConfig.js","sourceRoot":"","sources":["../src/springConfig.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,gBAAgB,CAAC;AAGrC,4FAA4F;AAC5F,mGAAmG;AACnG,gGAAgG;AAChG,MAAM,UAAU,kBAAkB,CAAC,SAAiB,EAAE,YAAoB;IACxE,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,CAAC;AACrC,CAAC;AAED,oGAAoG;AACpG,qFAAqF;AACrF,sFAAsF;AACtF,qEAAqE;AACrE,MAAM,UAAU,8BAA8B,CAAC,SAAiB,EAAE,OAAe,EAAE,IAAY;IAC7F,OAAO;QACL,YAAY,EAAE,OAAO,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,CAAC;QACzD,SAAS,EAAE,IAAI,CAAC,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,GAAG,GAAG;KAC7C,CAAC;AACJ,CAAC"}
@@ -0,0 +1,3 @@
1
+ import type { Spring, SpringConfig } from '@flighthq/types';
2
+ export declare function updateSpring(spring: Spring, target: number, config: Readonly<SpringConfig>, deltaTime: number): void;
3
+ //# sourceMappingURL=updateSpring.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"updateSpring.d.ts","sourceRoot":"","sources":["../src/updateSpring.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAmB5D,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CA+DpH"}
@@ -0,0 +1,81 @@
1
+ import { TAU } from '@flighthq/math';
2
+ // Advance `spring` one step of `deltaTime` seconds toward `target` under `config`, writing the new
3
+ // `value` and `velocity` back into `spring`. This is the scalar primitive; `updateSpring2D` and
4
+ // `updateSpring3D` call it once per component. The `target` is per-step, not stored on the spring,
5
+ // so it can change every frame.
6
+ //
7
+ // The step is the closed-form analytic solution of the damped-harmonic equation
8
+ // `c'' + 2*zeta*omega*c' + omega^2*c = 0` (where `c = value - target`, `omega = 2*PI*frequency`,
9
+ // `zeta = dampingRatio`), evaluated exactly over `deltaTime`. Because it is exact rather than an
10
+ // explicit-Euler approximation, it is unconditionally stable: for a stiff spring at a large
11
+ // `deltaTime` the decaying exponentials underflow to 0 and the coefficients stay bounded, so the
12
+ // spring lands on the target with zero velocity instead of exploding. It behaves consistently across
13
+ // frame rates (it is the analytic answer at any `deltaTime`) up to the usual limit that a target
14
+ // which itself moves between steps is only sampled at step boundaries.
15
+ //
16
+ // `deltaTime <= 0` is a no-op. A non-positive `frequency` is an inert spring (no force, no change);
17
+ // `dampingRatio` is clamped to `>= 0`. Inputs are read into locals before any write, so passing a
18
+ // `spring` that also appears elsewhere is safe.
19
+ export function updateSpring(spring, target, config, deltaTime) {
20
+ if (deltaTime <= 0)
21
+ return;
22
+ const frequency = config.frequency;
23
+ if (frequency <= 0)
24
+ return;
25
+ const value = spring.value;
26
+ const velocity = spring.velocity;
27
+ const dampingRatio = config.dampingRatio < 0 ? 0 : config.dampingRatio;
28
+ const omega = TAU * frequency;
29
+ // Offset from equilibrium; the analytic solution is in terms of this displacement.
30
+ const c0 = value - target;
31
+ let posPosCoef;
32
+ let posVelCoef;
33
+ let velPosCoef;
34
+ let velVelCoef;
35
+ if (dampingRatio > 1 + CRITICAL_BAND) {
36
+ // Overdamped: two distinct real roots z1 < z2 < 0 of s^2 + 2*zeta*omega*s + omega^2.
37
+ const zb = omega * Math.sqrt(dampingRatio * dampingRatio - 1);
38
+ const za = -omega * dampingRatio;
39
+ const z1 = za - zb;
40
+ const z2 = za + zb;
41
+ const e1 = Math.exp(z1 * deltaTime);
42
+ const e2 = Math.exp(z2 * deltaTime);
43
+ const invDenominator = 1 / (z2 - z1);
44
+ posPosCoef = (z2 * e1 - z1 * e2) * invDenominator;
45
+ posVelCoef = (e2 - e1) * invDenominator;
46
+ // z1 * z2 == omega^2 (product of the roots).
47
+ velPosCoef = z1 * z2 * (e1 - e2) * invDenominator;
48
+ velVelCoef = (z2 * e2 - z1 * e1) * invDenominator;
49
+ }
50
+ else if (dampingRatio < 1 - CRITICAL_BAND) {
51
+ // Underdamped: complex roots -alpha +/- i*beta; oscillates within a decaying envelope.
52
+ const alpha = dampingRatio * omega;
53
+ const beta = omega * Math.sqrt(1 - dampingRatio * dampingRatio);
54
+ const envelope = Math.exp(-alpha * deltaTime);
55
+ const cosine = Math.cos(beta * deltaTime);
56
+ const sine = Math.sin(beta * deltaTime);
57
+ const invBeta = 1 / beta;
58
+ posPosCoef = envelope * (cosine + alpha * invBeta * sine);
59
+ posVelCoef = envelope * invBeta * sine;
60
+ // omega^2 == alpha^2 + beta^2.
61
+ velPosCoef = -envelope * omega * omega * invBeta * sine;
62
+ velVelCoef = envelope * (cosine - alpha * invBeta * sine);
63
+ }
64
+ else {
65
+ // Critically damped (dampingRatio within CRITICAL_BAND of 1): a repeated real root -omega. Using
66
+ // the closed form here also avoids the divide-by-zero the over/under branches hit exactly at 1.
67
+ const envelope = Math.exp(-omega * deltaTime);
68
+ const omegaDt = omega * deltaTime;
69
+ posPosCoef = envelope * (1 + omegaDt);
70
+ posVelCoef = envelope * deltaTime;
71
+ velPosCoef = -envelope * omega * omega * deltaTime;
72
+ velVelCoef = envelope * (1 - omegaDt);
73
+ }
74
+ spring.value = target + posPosCoef * c0 + posVelCoef * velocity;
75
+ spring.velocity = velPosCoef * c0 + velVelCoef * velocity;
76
+ }
77
+ // Half-width of the damping-ratio band around 1 treated as critically damped. Inside it the
78
+ // over/under-damped closed forms divide by a vanishing root separation, so the critical form is both
79
+ // numerically safer and physically indistinguishable this close to 1.
80
+ const CRITICAL_BAND = 1e-4;
81
+ //# sourceMappingURL=updateSpring.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"updateSpring.js","sourceRoot":"","sources":["../src/updateSpring.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@flighthq/spring",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "src/**/*.test.ts",
16
+ "!dist/**/*.test.js",
17
+ "!dist/**/*.test.d.ts",
18
+ "!dist/**/*.test.js.map",
19
+ "!dist/**/*.test.d.ts.map"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc -b",
23
+ "clean": "tsc -b --clean",
24
+ "test": "vitest run --config vitest.config.ts",
25
+ "test:watch": "vitest --watch --config vitest.config.ts",
26
+ "prepack": "npm run clean && npm run clean:dist && npm run build",
27
+ "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
28
+ },
29
+ "dependencies": {
30
+ "@flighthq/math": "0.1.0",
31
+ "@flighthq/types": "0.1.0"
32
+ },
33
+ "devDependencies": {
34
+ "typescript": "^5.3.0"
35
+ },
36
+ "description": "Spring-physics animation — plain-data damped-harmonic springs (frequency + damping-ratio config, physical stiffness/damping/mass constructor) stepped by a numerically stable analytic integrator that stays finite for stiff springs at any deltaTime; scalar springs plus componentwise 2D/3D vector springs, settle detection, and reset",
37
+ "sideEffects": false
38
+ }
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { createSpring } from './createSpring';
4
+
5
+ describe('createSpring', () => {
6
+ it('defaults value and velocity to 0', () => {
7
+ const spring = createSpring();
8
+ expect(spring.value).toBe(0);
9
+ expect(spring.velocity).toBe(0);
10
+ });
11
+
12
+ it('sets the provided value and velocity', () => {
13
+ const spring = createSpring(5, -2);
14
+ expect(spring.value).toBe(5);
15
+ expect(spring.velocity).toBe(-2);
16
+ });
17
+ });
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { createSpring } from './createSpring';
4
+ import { isSpringSettled } from './isSpringSettled';
5
+ import { createSpringConfig } from './springConfig';
6
+ import { updateSpring } from './updateSpring';
7
+
8
+ describe('isSpringSettled', () => {
9
+ it('is false while the spring is still moving toward the target', () => {
10
+ const spring = createSpring(0);
11
+ const config = createSpringConfig(1, 1);
12
+ updateSpring(spring, 10, config, 1 / 60);
13
+ expect(isSpringSettled(spring, 10)).toBe(false);
14
+ });
15
+
16
+ it('is true once the spring rests at the target', () => {
17
+ const spring = createSpring(0);
18
+ const config = createSpringConfig(4, 1);
19
+ for (let i = 0; i < 600; i++) updateSpring(spring, 10, config, 1 / 60);
20
+ expect(isSpringSettled(spring, 10)).toBe(true);
21
+ });
22
+
23
+ it('is false at the target when velocity is still high (mid overshoot)', () => {
24
+ // At the target position but moving fast is not settled.
25
+ const spring = createSpring(10, 50);
26
+ expect(isSpringSettled(spring, 10)).toBe(false);
27
+ });
28
+
29
+ it('is false when at rest but away from the target', () => {
30
+ const spring = createSpring(3, 0);
31
+ expect(isSpringSettled(spring, 10)).toBe(false);
32
+ });
33
+
34
+ it('honors custom epsilons', () => {
35
+ const spring = createSpring(10.4, 0.4);
36
+ expect(isSpringSettled(spring, 10)).toBe(false);
37
+ expect(isSpringSettled(spring, 10, 0.5, 0.5)).toBe(true);
38
+ });
39
+ });
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { createSpring } from './createSpring';
4
+ import { resetSpring } from './resetSpring';
5
+
6
+ describe('resetSpring', () => {
7
+ it('snaps value and velocity, defaulting velocity to 0', () => {
8
+ const spring = createSpring(3, 9);
9
+ resetSpring(spring, 100);
10
+ expect(spring.value).toBe(100);
11
+ expect(spring.velocity).toBe(0);
12
+ });
13
+
14
+ it('sets an explicit velocity', () => {
15
+ const spring = createSpring(0, 0);
16
+ resetSpring(spring, -5, 12);
17
+ expect(spring.value).toBe(-5);
18
+ expect(spring.velocity).toBe(12);
19
+ });
20
+ });
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { createSpring2D, isSpring2DSettled, updateSpring2D } from './spring2D';
4
+ import { createSpringConfig } from './springConfig';
5
+
6
+ describe('createSpring2D', () => {
7
+ it('defaults both axes to value 0 and velocity 0', () => {
8
+ const spring = createSpring2D();
9
+ expect(spring.x.value).toBe(0);
10
+ expect(spring.x.velocity).toBe(0);
11
+ expect(spring.y.value).toBe(0);
12
+ expect(spring.y.velocity).toBe(0);
13
+ });
14
+
15
+ it('sets per-axis value and velocity', () => {
16
+ const spring = createSpring2D(1, 2, 3, 4);
17
+ expect(spring.x.value).toBe(1);
18
+ expect(spring.y.value).toBe(2);
19
+ expect(spring.x.velocity).toBe(3);
20
+ expect(spring.y.velocity).toBe(4);
21
+ });
22
+ });
23
+
24
+ describe('isSpring2DSettled', () => {
25
+ it('is false until both axes rest at their targets', () => {
26
+ const spring = createSpring2D(0, 0);
27
+ const config = createSpringConfig(4, 1);
28
+ expect(isSpring2DSettled(spring, 10, -6)).toBe(false);
29
+ for (let i = 0; i < 600; i++) updateSpring2D(spring, 10, -6, config, 1 / 60);
30
+ expect(isSpring2DSettled(spring, 10, -6)).toBe(true);
31
+ });
32
+ });
33
+
34
+ describe('updateSpring2D', () => {
35
+ it('moves both components toward their targets', () => {
36
+ const spring = createSpring2D(0, 0);
37
+ const config = createSpringConfig(2, 1);
38
+ updateSpring2D(spring, 10, -20, config, 1 / 60);
39
+ expect(spring.x.value).toBeGreaterThan(0);
40
+ expect(spring.y.value).toBeLessThan(0);
41
+ });
42
+
43
+ it('drives the axes independently (matching the scalar solver per component)', () => {
44
+ // x and y use the same config but different targets, so they should reach different values.
45
+ const spring = createSpring2D(0, 0);
46
+ const config = createSpringConfig(2, 0.8);
47
+ for (let i = 0; i < 30; i++) updateSpring2D(spring, 10, 5, config, 1 / 60);
48
+ expect(spring.x.value).not.toBeCloseTo(spring.y.value, 3);
49
+ });
50
+ });
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { createSpring3D, isSpring3DSettled, updateSpring3D } from './spring3D';
4
+ import { createSpringConfig } from './springConfig';
5
+
6
+ describe('createSpring3D', () => {
7
+ it('defaults all axes to value 0 and velocity 0', () => {
8
+ const spring = createSpring3D();
9
+ expect(spring.x.value).toBe(0);
10
+ expect(spring.y.value).toBe(0);
11
+ expect(spring.z.value).toBe(0);
12
+ expect(spring.x.velocity).toBe(0);
13
+ expect(spring.y.velocity).toBe(0);
14
+ expect(spring.z.velocity).toBe(0);
15
+ });
16
+
17
+ it('sets per-axis value and velocity', () => {
18
+ const spring = createSpring3D(1, 2, 3, 4, 5, 6);
19
+ expect(spring.x.value).toBe(1);
20
+ expect(spring.y.value).toBe(2);
21
+ expect(spring.z.value).toBe(3);
22
+ expect(spring.x.velocity).toBe(4);
23
+ expect(spring.y.velocity).toBe(5);
24
+ expect(spring.z.velocity).toBe(6);
25
+ });
26
+ });
27
+
28
+ describe('isSpring3DSettled', () => {
29
+ it('is false until all three axes rest at their targets', () => {
30
+ const spring = createSpring3D(0, 0, 0);
31
+ const config = createSpringConfig(4, 1);
32
+ expect(isSpring3DSettled(spring, 10, -6, 3)).toBe(false);
33
+ for (let i = 0; i < 600; i++) updateSpring3D(spring, 10, -6, 3, config, 1 / 60);
34
+ expect(isSpring3DSettled(spring, 10, -6, 3)).toBe(true);
35
+ });
36
+ });
37
+
38
+ describe('updateSpring3D', () => {
39
+ it('moves all three components toward their targets', () => {
40
+ const spring = createSpring3D(0, 0, 0);
41
+ const config = createSpringConfig(2, 1);
42
+ updateSpring3D(spring, 10, -20, 30, config, 1 / 60);
43
+ expect(spring.x.value).toBeGreaterThan(0);
44
+ expect(spring.y.value).toBeLessThan(0);
45
+ expect(spring.z.value).toBeGreaterThan(0);
46
+ });
47
+ });
@@ -0,0 +1,27 @@
1
+ import { TAU } from '@flighthq/math';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { createSpringConfig, createSpringConfigFromPhysical } from './springConfig';
5
+
6
+ describe('createSpringConfig', () => {
7
+ it('stores frequency and dampingRatio verbatim', () => {
8
+ const config = createSpringConfig(2.5, 0.4);
9
+ expect(config.frequency).toBe(2.5);
10
+ expect(config.dampingRatio).toBe(0.4);
11
+ });
12
+ });
13
+
14
+ describe('createSpringConfigFromPhysical', () => {
15
+ it('maps k=100, c=20, m=1 to frequency = sqrt(k/m)/(2*PI) and dampingRatio = 1 (critical)', () => {
16
+ const config = createSpringConfigFromPhysical(100, 20, 1);
17
+ expect(config.frequency).toBeCloseTo(10 / TAU, 9);
18
+ expect(config.dampingRatio).toBeCloseTo(1, 12);
19
+ });
20
+
21
+ it('accounts for mass in both frequency and damping ratio', () => {
22
+ // k=100, m=4 -> sqrt(25)=5 -> frequency 5/TAU; c=20 -> 20/(2*sqrt(400))=0.5.
23
+ const config = createSpringConfigFromPhysical(100, 20, 4);
24
+ expect(config.frequency).toBeCloseTo(5 / TAU, 9);
25
+ expect(config.dampingRatio).toBeCloseTo(0.5, 12);
26
+ });
27
+ });
@@ -0,0 +1,138 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { createSpring } from './createSpring';
4
+ import { isSpringSettled } from './isSpringSettled';
5
+ import { createSpringConfig } from './springConfig';
6
+ import { updateSpring } from './updateSpring';
7
+
8
+ describe('updateSpring', () => {
9
+ it('converges monotonically to the target with no overshoot when critically damped', () => {
10
+ const spring = createSpring(0);
11
+ const config = createSpringConfig(1, 1);
12
+ const target = 10;
13
+
14
+ let previous = spring.value;
15
+ for (let i = 0; i < 600; i++) {
16
+ updateSpring(spring, target, config, 1 / 60);
17
+ // Critical damping never overshoots and never reverses on a step-to-rest.
18
+ expect(spring.value).toBeGreaterThanOrEqual(previous - 1e-9);
19
+ expect(spring.value).toBeLessThanOrEqual(target + 1e-9);
20
+ previous = spring.value;
21
+ }
22
+ expect(spring.value).toBeCloseTo(target, 3);
23
+ expect(isSpringSettled(spring, target)).toBe(true);
24
+ });
25
+
26
+ it('overshoots the target at least once then settles when underdamped', () => {
27
+ const spring = createSpring(0);
28
+ const config = createSpringConfig(1, 0.3);
29
+ const target = 10;
30
+
31
+ let overshot = false;
32
+ for (let i = 0; i < 2000; i++) {
33
+ updateSpring(spring, target, config, 1 / 60);
34
+ if (spring.value > target + 0.05) overshot = true;
35
+ }
36
+ expect(overshot).toBe(true);
37
+ expect(isSpringSettled(spring, target)).toBe(true);
38
+ });
39
+
40
+ it('converges slowly without overshoot when overdamped', () => {
41
+ const underTarget = createSpring(0);
42
+ const config = createSpringConfig(1, 2);
43
+ const target = 10;
44
+
45
+ let previous = underTarget.value;
46
+ for (let i = 0; i < 120; i++) {
47
+ updateSpring(underTarget, target, config, 1 / 60);
48
+ expect(underTarget.value).toBeGreaterThanOrEqual(previous - 1e-9);
49
+ expect(underTarget.value).toBeLessThanOrEqual(target + 1e-9);
50
+ previous = underTarget.value;
51
+ }
52
+
53
+ // After the same wall time an overdamped spring lags behind a critically damped one.
54
+ const critical = createSpring(0);
55
+ const criticalConfig = createSpringConfig(1, 1);
56
+ for (let i = 0; i < 120; i++) updateSpring(critical, target, criticalConfig, 1 / 60);
57
+ expect(underTarget.value).toBeLessThan(critical.value);
58
+ });
59
+
60
+ it('stays finite and bounded for a stiff spring stepped at a huge deltaTime', () => {
61
+ const spring = createSpring(0);
62
+ const config = createSpringConfig(1000, 1);
63
+ const target = 10;
64
+
65
+ // An explicit-Euler integrator would explode here; the analytic step lands on the target.
66
+ updateSpring(spring, target, config, 1);
67
+ expect(Number.isFinite(spring.value)).toBe(true);
68
+ expect(Number.isFinite(spring.velocity)).toBe(true);
69
+ expect(spring.value).toBeCloseTo(target, 6);
70
+ expect(spring.velocity).toBeCloseTo(0, 6);
71
+ });
72
+
73
+ it('stays finite for a stiff underdamped spring stepped repeatedly at a huge deltaTime', () => {
74
+ const spring = createSpring(0);
75
+ const config = createSpringConfig(1000, 0.5);
76
+ const target = 10;
77
+
78
+ for (let i = 0; i < 50; i++) {
79
+ updateSpring(spring, target, config, 1);
80
+ expect(Number.isFinite(spring.value)).toBe(true);
81
+ expect(Number.isFinite(spring.velocity)).toBe(true);
82
+ expect(Math.abs(spring.value)).toBeLessThan(100);
83
+ }
84
+ expect(isSpringSettled(spring, target)).toBe(true);
85
+ });
86
+
87
+ it('is frame-rate independent for a constant target (analytic semigroup)', () => {
88
+ const config = createSpringConfig(2, 0.5);
89
+ const target = 7;
90
+
91
+ // 60 small steps of 1/60s cover exactly the same second as one 1s step; the exact analytic
92
+ // solution makes them agree to floating tolerance (M(dt)^60 == M(1)).
93
+ const fine = createSpring(0, 3);
94
+ for (let i = 0; i < 60; i++) updateSpring(fine, target, config, 1 / 60);
95
+
96
+ const coarse = createSpring(0, 3);
97
+ updateSpring(coarse, target, config, 1);
98
+
99
+ expect(fine.value).toBeCloseTo(coarse.value, 6);
100
+ expect(fine.velocity).toBeCloseTo(coarse.velocity, 6);
101
+ });
102
+
103
+ it('is a no-op for deltaTime <= 0', () => {
104
+ const spring = createSpring(4, 2);
105
+ const config = createSpringConfig(1, 1);
106
+
107
+ updateSpring(spring, 100, config, 0);
108
+ expect(spring.value).toBe(4);
109
+ expect(spring.velocity).toBe(2);
110
+
111
+ updateSpring(spring, 100, config, -1);
112
+ expect(spring.value).toBe(4);
113
+ expect(spring.velocity).toBe(2);
114
+ });
115
+
116
+ it('is inert for a non-positive frequency', () => {
117
+ const spring = createSpring(4, 2);
118
+ updateSpring(spring, 100, createSpringConfig(0, 1), 1 / 60);
119
+ expect(spring.value).toBe(4);
120
+ expect(spring.velocity).toBe(2);
121
+ });
122
+
123
+ it('reads value and velocity before writing (no self-clobber)', () => {
124
+ // The new value must use the OLD velocity; if the step wrote value before reading velocity the
125
+ // result would differ. A hand-rolled second reference spring from the same state must match.
126
+ const spring = createSpring(5, 2);
127
+ const reference = createSpring(5, 2);
128
+ const config = createSpringConfig(3, 0.7);
129
+
130
+ updateSpring(spring, 0, config, 1 / 60);
131
+ updateSpring(reference, 0, config, 1 / 60);
132
+ expect(spring.value).toBe(reference.value);
133
+ expect(spring.velocity).toBe(reference.velocity);
134
+ // A meaningful step actually moved and imparted velocity.
135
+ expect(spring.value).not.toBe(5);
136
+ expect(spring.velocity).not.toBe(2);
137
+ });
138
+ });