@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.
- package/dist/createSpring.d.ts +3 -0
- package/dist/createSpring.d.ts.map +1 -0
- package/dist/createSpring.js +7 -0
- package/dist/createSpring.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/isSpringSettled.d.ts +3 -0
- package/dist/isSpringSettled.d.ts.map +1 -0
- package/dist/isSpringSettled.js +15 -0
- package/dist/isSpringSettled.js.map +1 -0
- package/dist/resetSpring.d.ts +3 -0
- package/dist/resetSpring.d.ts.map +1 -0
- package/dist/resetSpring.js +8 -0
- package/dist/resetSpring.js.map +1 -0
- package/dist/spring2D.d.ts +5 -0
- package/dist/spring2D.d.ts.map +1 -0
- package/dist/spring2D.js +22 -0
- package/dist/spring2D.js.map +1 -0
- package/dist/spring3D.d.ts +5 -0
- package/dist/spring3D.d.ts.map +1 -0
- package/dist/spring3D.js +27 -0
- package/dist/spring3D.js.map +1 -0
- package/dist/springConfig.d.ts +4 -0
- package/dist/springConfig.d.ts.map +1 -0
- package/dist/springConfig.js +18 -0
- package/dist/springConfig.js.map +1 -0
- package/dist/updateSpring.d.ts +3 -0
- package/dist/updateSpring.d.ts.map +1 -0
- package/dist/updateSpring.js +81 -0
- package/dist/updateSpring.js.map +1 -0
- package/package.json +38 -0
- package/src/createSpring.test.ts +17 -0
- package/src/isSpringSettled.test.ts +39 -0
- package/src/resetSpring.test.ts +20 -0
- package/src/spring2D.test.ts +50 -0
- package/src/spring3D.test.ts +47 -0
- package/src/springConfig.test.ts +27 -0
- package/src/updateSpring.test.ts +138 -0
|
@@ -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"}
|
package/dist/index.d.ts
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.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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
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"}
|
package/dist/spring2D.js
ADDED
|
@@ -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"}
|
package/dist/spring3D.js
ADDED
|
@@ -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 @@
|
|
|
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
|
+
});
|