tensum 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 (134) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +21 -0
  3. package/README.md +340 -0
  4. package/dist/coupled-system.d.ts +50 -0
  5. package/dist/coupled-system.d.ts.map +1 -0
  6. package/dist/coupled-system.js +269 -0
  7. package/dist/coupled-system.js.map +1 -0
  8. package/dist/coupled.d.ts +3 -0
  9. package/dist/coupled.d.ts.map +1 -0
  10. package/dist/coupled.js +2 -0
  11. package/dist/coupled.js.map +1 -0
  12. package/dist/css-linear.d.ts +20 -0
  13. package/dist/css-linear.d.ts.map +1 -0
  14. package/dist/css-linear.js +95 -0
  15. package/dist/css-linear.js.map +1 -0
  16. package/dist/css.d.ts +3 -0
  17. package/dist/css.d.ts.map +1 -0
  18. package/dist/css.js +2 -0
  19. package/dist/css.js.map +1 -0
  20. package/dist/gsap/active-tracks.d.ts +32 -0
  21. package/dist/gsap/active-tracks.d.ts.map +1 -0
  22. package/dist/gsap/active-tracks.js +177 -0
  23. package/dist/gsap/active-tracks.js.map +1 -0
  24. package/dist/gsap/gsap-time.d.ts +10 -0
  25. package/dist/gsap/gsap-time.d.ts.map +1 -0
  26. package/dist/gsap/gsap-time.js +62 -0
  27. package/dist/gsap/gsap-time.js.map +1 -0
  28. package/dist/gsap/plugin-tween-coordinator.d.ts +30 -0
  29. package/dist/gsap/plugin-tween-coordinator.d.ts.map +1 -0
  30. package/dist/gsap/plugin-tween-coordinator.js +72 -0
  31. package/dist/gsap/plugin-tween-coordinator.js.map +1 -0
  32. package/dist/gsap/plugin.d.ts +84 -0
  33. package/dist/gsap/plugin.d.ts.map +1 -0
  34. package/dist/gsap/plugin.js +552 -0
  35. package/dist/gsap/plugin.js.map +1 -0
  36. package/dist/gsap/spring-to.d.ts +93 -0
  37. package/dist/gsap/spring-to.d.ts.map +1 -0
  38. package/dist/gsap/spring-to.js +569 -0
  39. package/dist/gsap/spring-to.js.map +1 -0
  40. package/dist/gsap/spring-track-policy.d.ts +24 -0
  41. package/dist/gsap/spring-track-policy.d.ts.map +1 -0
  42. package/dist/gsap/spring-track-policy.js +76 -0
  43. package/dist/gsap/spring-track-policy.js.map +1 -0
  44. package/dist/gsap/track-lifecycle.d.ts +16 -0
  45. package/dist/gsap/track-lifecycle.d.ts.map +1 -0
  46. package/dist/gsap/track-lifecycle.js +33 -0
  47. package/dist/gsap/track-lifecycle.js.map +1 -0
  48. package/dist/index.d.ts +28 -0
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.js +16 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/math.d.ts +9 -0
  53. package/dist/math.d.ts.map +1 -0
  54. package/dist/math.js +38 -0
  55. package/dist/math.js.map +1 -0
  56. package/dist/model.d.ts +14 -0
  57. package/dist/model.d.ts.map +1 -0
  58. package/dist/model.js +38 -0
  59. package/dist/model.js.map +1 -0
  60. package/dist/motion-compatibility.d.ts +18 -0
  61. package/dist/motion-compatibility.d.ts.map +1 -0
  62. package/dist/motion-compatibility.js +83 -0
  63. package/dist/motion-compatibility.js.map +1 -0
  64. package/dist/parameters.d.ts +52 -0
  65. package/dist/parameters.d.ts.map +1 -0
  66. package/dist/parameters.js +124 -0
  67. package/dist/parameters.js.map +1 -0
  68. package/dist/settling.d.ts +12 -0
  69. package/dist/settling.d.ts.map +1 -0
  70. package/dist/settling.js +107 -0
  71. package/dist/settling.js.map +1 -0
  72. package/dist/solver.d.ts +17 -0
  73. package/dist/solver.d.ts.map +1 -0
  74. package/dist/solver.js +190 -0
  75. package/dist/solver.js.map +1 -0
  76. package/dist/spring.d.ts +9 -0
  77. package/dist/spring.d.ts.map +1 -0
  78. package/dist/spring.js +91 -0
  79. package/dist/spring.js.map +1 -0
  80. package/dist/support/additive-spring-value.d.ts +39 -0
  81. package/dist/support/additive-spring-value.d.ts.map +1 -0
  82. package/dist/support/additive-spring-value.js +281 -0
  83. package/dist/support/additive-spring-value.js.map +1 -0
  84. package/dist/support/inertia.d.ts +34 -0
  85. package/dist/support/inertia.d.ts.map +1 -0
  86. package/dist/support/inertia.js +293 -0
  87. package/dist/support/inertia.js.map +1 -0
  88. package/dist/support/keyframes.d.ts +34 -0
  89. package/dist/support/keyframes.d.ts.map +1 -0
  90. package/dist/support/keyframes.js +117 -0
  91. package/dist/support/keyframes.js.map +1 -0
  92. package/dist/support/spring-value.d.ts +38 -0
  93. package/dist/support/spring-value.d.ts.map +1 -0
  94. package/dist/support/spring-value.js +289 -0
  95. package/dist/support/spring-value.js.map +1 -0
  96. package/dist/support/velocity.d.ts +14 -0
  97. package/dist/support/velocity.d.ts.map +1 -0
  98. package/dist/support/velocity.js +75 -0
  99. package/dist/support/velocity.js.map +1 -0
  100. package/dist/types.d.ts +61 -0
  101. package/dist/types.d.ts.map +1 -0
  102. package/dist/types.js +2 -0
  103. package/dist/types.js.map +1 -0
  104. package/dist/vector.d.ts +32 -0
  105. package/dist/vector.d.ts.map +1 -0
  106. package/dist/vector.js +121 -0
  107. package/dist/vector.js.map +1 -0
  108. package/package.json +68 -0
  109. package/src/coupled-system.ts +383 -0
  110. package/src/coupled.ts +10 -0
  111. package/src/css-linear.ts +148 -0
  112. package/src/css.ts +6 -0
  113. package/src/gsap/active-tracks.ts +244 -0
  114. package/src/gsap/gsap-time.ts +91 -0
  115. package/src/gsap/plugin-tween-coordinator.ts +112 -0
  116. package/src/gsap/plugin.ts +861 -0
  117. package/src/gsap/spring-to.ts +836 -0
  118. package/src/gsap/spring-track-policy.ts +133 -0
  119. package/src/gsap/track-lifecycle.ts +53 -0
  120. package/src/index.ts +130 -0
  121. package/src/math.ts +46 -0
  122. package/src/model.ts +70 -0
  123. package/src/motion-compatibility.ts +131 -0
  124. package/src/parameters.ts +226 -0
  125. package/src/settling.ts +141 -0
  126. package/src/solver.ts +262 -0
  127. package/src/spring.ts +119 -0
  128. package/src/support/additive-spring-value.ts +343 -0
  129. package/src/support/inertia.ts +367 -0
  130. package/src/support/keyframes.ts +188 -0
  131. package/src/support/spring-value.ts +399 -0
  132. package/src/support/velocity.ts +119 -0
  133. package/src/types.ts +70 -0
  134. package/src/vector.ts +191 -0
@@ -0,0 +1,226 @@
1
+ import {
2
+ angularFrequency,
3
+ assertFinite,
4
+ classifyDamping,
5
+ dampingRatio,
6
+ } from './math.js';
7
+ import { validateSpringParameters } from './solver.js';
8
+ import type { SpringParameters, SpringRegime } from './types.js';
9
+
10
+ const DEFAULT_MASS = 1;
11
+ const TWO_PI = 2 * Math.PI;
12
+
13
+ export interface ResponseSpringInput {
14
+ response: number;
15
+ dampingRatio: number;
16
+ mass?: number;
17
+ }
18
+
19
+ export interface PerceptualSpringInput {
20
+ duration: number;
21
+ bounce: number;
22
+ mass?: number;
23
+ }
24
+
25
+ export interface VisualSpringInput {
26
+ visualDuration: number;
27
+ bounce: number;
28
+ mass?: number;
29
+ }
30
+
31
+ export interface SettlingDurationSpringInput {
32
+ duration: number;
33
+ dampingRatio: number;
34
+ epsilon: number;
35
+ mass?: number;
36
+ }
37
+
38
+ export interface SpringCharacteristics {
39
+ angularFrequency: number;
40
+ dampingRatio: number;
41
+ regime: SpringRegime;
42
+ response: number;
43
+ bounce: number;
44
+ perceptualDuration: number;
45
+ }
46
+
47
+ export interface SpringParameterConverters {
48
+ fromPhysics(parameters: SpringParameters): Readonly<SpringParameters>;
49
+ fromResponse(input: ResponseSpringInput): Readonly<SpringParameters>;
50
+ fromPerceptualDuration(input: PerceptualSpringInput): Readonly<SpringParameters>;
51
+ fromSettlingDuration(input: SettlingDurationSpringInput): Readonly<SpringParameters>;
52
+ fromVisualDuration(input: VisualSpringInput): Readonly<SpringParameters>;
53
+ }
54
+
55
+ export interface SpringPresetOptions {
56
+ duration?: number;
57
+ extraBounce?: number;
58
+ mass?: number;
59
+ }
60
+
61
+ export type SpringPreset = (
62
+ options?: SpringPresetOptions,
63
+ ) => Readonly<SpringParameters>;
64
+
65
+ export interface SpringPresets {
66
+ smooth: SpringPreset;
67
+ snappy: SpringPreset;
68
+ bouncy: SpringPreset;
69
+ }
70
+
71
+ function assertPositive(name: string, value: number): void {
72
+ assertFinite(name, value);
73
+ if (value <= 0) throw new RangeError(`${name} must be greater than 0`);
74
+ }
75
+
76
+ function assertNonNegative(name: string, value: number): void {
77
+ assertFinite(name, value);
78
+ if (value < 0) throw new RangeError(`${name} must be greater than or equal to 0`);
79
+ }
80
+
81
+ function resolvedMass(mass: number | undefined): number {
82
+ const value = mass === undefined ? DEFAULT_MASS : mass;
83
+ assertPositive('mass', value);
84
+ return value;
85
+ }
86
+
87
+ function freezeParameters(parameters: SpringParameters): Readonly<SpringParameters> {
88
+ validateSpringParameters(parameters);
89
+ const frequency = angularFrequency(parameters);
90
+ const ratio = dampingRatio(parameters);
91
+ assertPositive('angularFrequency', frequency);
92
+ assertFinite('dampingRatio', ratio);
93
+ return Object.freeze({ ...parameters });
94
+ }
95
+
96
+ function fromFrequency(
97
+ frequency: number,
98
+ ratio: number,
99
+ mass: number,
100
+ ): Readonly<SpringParameters> {
101
+ assertPositive('angularFrequency', frequency);
102
+ assertNonNegative('dampingRatio', ratio);
103
+ const stiffness = mass * frequency * frequency;
104
+ let damping = 2 * ratio * mass * frequency;
105
+ if (!Number.isFinite(damping) || damping === 0) {
106
+ const candidates = [
107
+ 2 * mass * ratio * frequency,
108
+ 2 * frequency * mass * ratio,
109
+ 2 * mass * (ratio * frequency),
110
+ ];
111
+ const representable = candidates.find(
112
+ (candidate) => Number.isFinite(candidate) && candidate > 0,
113
+ );
114
+ if (representable !== undefined) damping = representable;
115
+ }
116
+ return freezeParameters({ mass, stiffness, damping });
117
+ }
118
+
119
+ function bounceToDampingRatio(bounce: number): number {
120
+ assertFinite('bounce', bounce);
121
+ if (bounce <= -1) {
122
+ throw new RangeError('bounce must be greater than -1 for finite spring parameters');
123
+ }
124
+ if (bounce >= 0) return 1 - Math.min(bounce, 1);
125
+ return 1 / (1 + bounce);
126
+ }
127
+
128
+ function dampingRatioToBounce(ratio: number): number {
129
+ return ratio <= 1 ? 1 - ratio : 1 / ratio - 1;
130
+ }
131
+
132
+ export function springCharacteristics(
133
+ parameters: SpringParameters,
134
+ ): Readonly<SpringCharacteristics> {
135
+ validateSpringParameters(parameters);
136
+ const frequency = angularFrequency(parameters);
137
+ const ratio = dampingRatio(parameters);
138
+ assertPositive('angularFrequency', frequency);
139
+ assertFinite('dampingRatio', ratio);
140
+ const response = TWO_PI / frequency;
141
+ assertPositive('response', response);
142
+
143
+ return Object.freeze({
144
+ angularFrequency: frequency,
145
+ dampingRatio: ratio,
146
+ regime: classifyDamping(ratio),
147
+ response,
148
+ bounce: dampingRatioToBounce(ratio),
149
+ perceptualDuration: response,
150
+ });
151
+ }
152
+
153
+ export const springParameters: SpringParameterConverters = Object.freeze({
154
+ fromPhysics(parameters: SpringParameters): Readonly<SpringParameters> {
155
+ return freezeParameters({
156
+ mass: parameters.mass,
157
+ stiffness: parameters.stiffness,
158
+ damping: parameters.damping,
159
+ });
160
+ },
161
+ fromResponse({
162
+ response,
163
+ dampingRatio: ratio,
164
+ mass,
165
+ }: ResponseSpringInput): Readonly<SpringParameters> {
166
+ assertPositive('response', response);
167
+ return fromFrequency(TWO_PI / response, ratio, resolvedMass(mass));
168
+ },
169
+ fromPerceptualDuration({
170
+ duration,
171
+ bounce,
172
+ mass,
173
+ }: PerceptualSpringInput): Readonly<SpringParameters> {
174
+ assertPositive('duration', duration);
175
+ return fromFrequency(
176
+ TWO_PI / duration,
177
+ bounceToDampingRatio(bounce),
178
+ resolvedMass(mass),
179
+ );
180
+ },
181
+ fromSettlingDuration({
182
+ duration,
183
+ dampingRatio: ratio,
184
+ epsilon,
185
+ mass,
186
+ }: SettlingDurationSpringInput): Readonly<SpringParameters> {
187
+ assertPositive('duration', duration);
188
+ assertPositive('dampingRatio', ratio);
189
+ assertFinite('epsilon', epsilon);
190
+ if (epsilon <= 0 || epsilon >= 1) {
191
+ throw new RangeError('epsilon must be greater than 0 and less than 1');
192
+ }
193
+ const frequency = -Math.log(epsilon) / (ratio * duration);
194
+ return fromFrequency(frequency, ratio, resolvedMass(mass));
195
+ },
196
+ fromVisualDuration({
197
+ visualDuration,
198
+ bounce,
199
+ mass,
200
+ }: VisualSpringInput): Readonly<SpringParameters> {
201
+ return springParameters.fromPerceptualDuration({
202
+ duration: visualDuration,
203
+ bounce,
204
+ ...(mass === undefined ? {} : { mass }),
205
+ });
206
+ },
207
+ });
208
+
209
+ function createPreset(baseBounce: number): SpringPreset {
210
+ return (options: SpringPresetOptions = {}): Readonly<SpringParameters> => {
211
+ const extraBounce =
212
+ options.extraBounce === undefined ? 0 : options.extraBounce;
213
+ assertFinite('extraBounce', extraBounce);
214
+ return springParameters.fromPerceptualDuration({
215
+ duration: options.duration === undefined ? 0.5 : options.duration,
216
+ bounce: baseBounce + extraBounce,
217
+ ...(options.mass === undefined ? {} : { mass: options.mass }),
218
+ });
219
+ };
220
+ }
221
+
222
+ export const springPresets: SpringPresets = Object.freeze({
223
+ smooth: createPreset(0),
224
+ snappy: createPreset(0.15),
225
+ bouncy: createPreset(0.3),
226
+ });
@@ -0,0 +1,141 @@
1
+ import { assertFinite } from './math.js';
2
+ import type { AnalyticalSolver } from './solver.js';
3
+ import type {
4
+ SettlingResult,
5
+ SpringSettleInput,
6
+ SpringSettlingOptions,
7
+ } from './types.js';
8
+
9
+ export const DEFAULT_SETTLING_OPTIONS: Readonly<SpringSettlingOptions> = {
10
+ positionEpsilon: 0.1,
11
+ velocityEpsilon: 0.1,
12
+ maxDuration: 60,
13
+ refinementIterations: 48,
14
+ };
15
+
16
+ export function resolveSettlingOptions(
17
+ input: SpringSettleInput = {},
18
+ ): Readonly<SpringSettlingOptions> {
19
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
20
+ throw new TypeError('settle must be an object when provided');
21
+ }
22
+ const options: SpringSettlingOptions = {
23
+ positionEpsilon:
24
+ input.position === undefined
25
+ ? DEFAULT_SETTLING_OPTIONS.positionEpsilon
26
+ : input.position,
27
+ velocityEpsilon:
28
+ input.velocity === undefined
29
+ ? DEFAULT_SETTLING_OPTIONS.velocityEpsilon
30
+ : input.velocity,
31
+ maxDuration:
32
+ input.maxDuration === undefined
33
+ ? DEFAULT_SETTLING_OPTIONS.maxDuration
34
+ : input.maxDuration,
35
+ refinementIterations:
36
+ input.refinementIterations === undefined
37
+ ? DEFAULT_SETTLING_OPTIONS.refinementIterations
38
+ : input.refinementIterations,
39
+ };
40
+ validateSettlingOptions(options);
41
+ return Object.freeze(options);
42
+ }
43
+
44
+ export function validateSettlingOptions(options: SpringSettlingOptions): void {
45
+ assertFinite('positionEpsilon', options.positionEpsilon);
46
+ assertFinite('velocityEpsilon', options.velocityEpsilon);
47
+ assertFinite('maxDuration', options.maxDuration);
48
+ assertFinite('refinementIterations', options.refinementIterations);
49
+
50
+ if (options.positionEpsilon <= 0) {
51
+ throw new RangeError('positionEpsilon must be greater than 0');
52
+ }
53
+ if (options.velocityEpsilon <= 0) {
54
+ throw new RangeError('velocityEpsilon must be greater than 0');
55
+ }
56
+ if (options.maxDuration <= 0) {
57
+ throw new RangeError('maxDuration must be greater than 0');
58
+ }
59
+ if (!Number.isInteger(options.refinementIterations)) {
60
+ throw new RangeError('refinementIterations must be an integer');
61
+ }
62
+ if (options.refinementIterations <= 0) {
63
+ throw new RangeError('refinementIterations must be greater than 0');
64
+ }
65
+ }
66
+
67
+ function boundsMeetThresholds(
68
+ solver: AnalyticalSolver,
69
+ time: number,
70
+ options: SpringSettlingOptions,
71
+ ): boolean {
72
+ const bounds = solver.tailBoundsAt(time);
73
+ return (
74
+ bounds.position <= options.positionEpsilon && bounds.velocity <= options.velocityEpsilon
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Finds the first time at which analytical tail envelopes are below both
80
+ * tolerances. The envelopes bound every future position error and velocity,
81
+ * so an underdamped zero-crossing cannot be mistaken for settlement.
82
+ */
83
+ export function getSettlingResult(
84
+ solver: AnalyticalSolver,
85
+ options: SpringSettlingOptions,
86
+ ): SettlingResult {
87
+ validateSettlingOptions(options);
88
+ const { maxDuration, refinementIterations } = options;
89
+
90
+ const initialBounds = solver.tailBoundsAt(0);
91
+ if (initialBounds.position === 0 && initialBounds.velocity === 0) {
92
+ return { duration: 0, iterations: 0, settled: true };
93
+ }
94
+
95
+ if (boundsMeetThresholds(solver, 0, options) && solver.tailBoundsMonotonicAfter === 0) {
96
+ return { duration: 0, iterations: 0, settled: true };
97
+ }
98
+
99
+ // An undamped non-equilibrium oscillator has a constant analytical envelope.
100
+ if (solver.dampingRatio === 0) {
101
+ return { duration: maxDuration, iterations: 0, settled: false };
102
+ }
103
+
104
+ if (solver.tailBoundsMonotonicAfter > maxDuration) {
105
+ return { duration: maxDuration, iterations: 0, settled: false };
106
+ }
107
+
108
+ let iterations = 0;
109
+ let lower = solver.tailBoundsMonotonicAfter;
110
+ let upper = Math.max(
111
+ lower,
112
+ Math.min(maxDuration, Math.max(1 / solver.angularFrequency, 1 / 120)),
113
+ );
114
+
115
+ if (boundsMeetThresholds(solver, lower, options)) {
116
+ // Critical-damping polynomial envelopes can grow before this point. Using
117
+ // the monotonic boundary is conservative and avoids an invalid binary
118
+ // search across that non-monotonic interval.
119
+ return { duration: lower, iterations, settled: true };
120
+ } else {
121
+ while (upper < maxDuration && !boundsMeetThresholds(solver, upper, options)) {
122
+ lower = upper;
123
+ upper = Math.min(maxDuration, upper * 2);
124
+ iterations += 1;
125
+ }
126
+ }
127
+
128
+ if (!boundsMeetThresholds(solver, upper, options)) {
129
+ return { duration: maxDuration, iterations, settled: false };
130
+ }
131
+
132
+ for (let index = 0; index < refinementIterations; index += 1) {
133
+ const midpoint = lower + (upper - lower) / 2;
134
+ if (midpoint === lower || midpoint === upper) break;
135
+ if (boundsMeetThresholds(solver, midpoint, options)) upper = midpoint;
136
+ else lower = midpoint;
137
+ iterations += 1;
138
+ }
139
+
140
+ return { duration: upper, iterations, settled: true };
141
+ }
package/src/solver.ts ADDED
@@ -0,0 +1,262 @@
1
+ import {
2
+ angularFrequency,
3
+ assertFinite,
4
+ assertNonNegativeTime,
5
+ classifyDamping,
6
+ dampingDecayRate,
7
+ dampingRatio,
8
+ } from './math.js';
9
+ import type {
10
+ SpringInitialState,
11
+ SpringParameters,
12
+ SpringRegime,
13
+ SpringState,
14
+ } from './types.js';
15
+
16
+ export interface AnalyticalSolver {
17
+ readonly angularFrequency: number;
18
+ readonly dampingRatio: number;
19
+ readonly regime: SpringRegime;
20
+ stateAt(time: number): SpringState;
21
+ tailBoundsAt(time: number): SpringState;
22
+ tailBoundsMonotonicAfter: number;
23
+ }
24
+
25
+ export function validateSpringParameters(parameters: SpringParameters): void {
26
+ assertFinite('mass', parameters.mass);
27
+ assertFinite('stiffness', parameters.stiffness);
28
+ assertFinite('damping', parameters.damping);
29
+
30
+ if (parameters.mass <= 0) throw new RangeError('mass must be greater than 0');
31
+ if (parameters.stiffness <= 0) throw new RangeError('stiffness must be greater than 0');
32
+ if (parameters.damping < 0) throw new RangeError('damping must be greater than or equal to 0');
33
+ }
34
+
35
+ export function validateInitialState(initial: SpringInitialState): void {
36
+ assertFinite('position', initial.position);
37
+ assertFinite('velocity', initial.velocity);
38
+ assertFinite('target', initial.target);
39
+ }
40
+
41
+ function finiteState(
42
+ position: number,
43
+ velocity: number,
44
+ suffix = '',
45
+ ): SpringState {
46
+ assertFinite(`position${suffix}`, position);
47
+ assertFinite(`velocity${suffix}`, velocity);
48
+ return { position, velocity };
49
+ }
50
+
51
+ function multiplyDivide(
52
+ first: number,
53
+ second: number,
54
+ divisor: number,
55
+ ): number {
56
+ const firstQuotient = (first / divisor) * second;
57
+ const secondQuotient = first * (second / divisor);
58
+ if (Number.isFinite(firstQuotient)) {
59
+ if (firstQuotient !== 0 || secondQuotient === 0) return firstQuotient;
60
+ }
61
+ return secondQuotient;
62
+ }
63
+
64
+ /**
65
+ * Solves m·x″ + c·x′ + k·(x - target) = 0 in closed form.
66
+ * Every sample is calculated from the initial conditions and absolute time.
67
+ */
68
+ export function createAnalyticalSolver(
69
+ parameters: SpringParameters,
70
+ initial: SpringInitialState,
71
+ ): AnalyticalSolver {
72
+ validateSpringParameters(parameters);
73
+ validateInitialState(initial);
74
+
75
+ // Keep the public solver deterministic if the caller later reuses and
76
+ // mutates its input object.
77
+ const initialPosition = initial.position;
78
+ const initialVelocity = initial.velocity;
79
+ const target = initial.target;
80
+
81
+ const omega0 = angularFrequency(parameters);
82
+ const alpha = dampingDecayRate(parameters);
83
+ const zeta = dampingRatio(parameters);
84
+
85
+ assertFinite('angularFrequency', omega0);
86
+ assertFinite('dampingDecayRate', alpha);
87
+ assertFinite('dampingRatio', zeta);
88
+ if (omega0 <= 0) {
89
+ throw new RangeError('spring parameters must produce a positive angularFrequency');
90
+ }
91
+
92
+ const regime = classifyDamping(zeta);
93
+ const y0 = initialPosition - target;
94
+ const v0 = initialVelocity;
95
+
96
+ assertFinite('initial displacement', y0);
97
+
98
+ if (regime === 'underdamped') {
99
+ const omegaD =
100
+ omega0 * Math.sqrt(Math.max(0, (1 - zeta) * (1 + zeta)));
101
+ const a = y0;
102
+ const b = (v0 + alpha * y0) / omegaD;
103
+ const velocityCos = -alpha * a + omegaD * b;
104
+ const velocitySin = -alpha * b - omegaD * a;
105
+ const positionAmplitude = Math.hypot(a, b);
106
+ const velocityAmplitude = Math.hypot(velocityCos, velocitySin);
107
+
108
+ for (const [name, value] of [
109
+ ['dampedAngularFrequency', omegaD],
110
+ ['positionCoefficient', b],
111
+ ['velocityCosCoefficient', velocityCos],
112
+ ['velocitySinCoefficient', velocitySin],
113
+ ['positionAmplitude', positionAmplitude],
114
+ ['velocityAmplitude', velocityAmplitude],
115
+ ] as const) {
116
+ assertFinite(name, value);
117
+ }
118
+
119
+ return {
120
+ angularFrequency: omega0,
121
+ dampingRatio: zeta,
122
+ regime,
123
+ stateAt(time) {
124
+ assertNonNegativeTime(time);
125
+ if (time === 0) {
126
+ return { position: initialPosition, velocity: initialVelocity };
127
+ }
128
+ const decay = Math.exp(-alpha * time);
129
+ if (decay === 0) return { position: target, velocity: 0 };
130
+ const rawPhase = omegaD * time;
131
+ const phase = Number.isFinite(rawPhase)
132
+ ? rawPhase
133
+ : (time % ((2 * Math.PI) / omegaD)) * omegaD;
134
+ const cosine = Math.cos(phase);
135
+ const sine = Math.sin(phase);
136
+
137
+ return finiteState(
138
+ target + decay * (a * cosine + b * sine),
139
+ decay * (velocityCos * cosine + velocitySin * sine),
140
+ );
141
+ },
142
+ tailBoundsAt(time) {
143
+ assertNonNegativeTime(time);
144
+ const decay = Math.exp(-alpha * time);
145
+ return finiteState(
146
+ decay * positionAmplitude,
147
+ decay * velocityAmplitude,
148
+ ' bound',
149
+ );
150
+ },
151
+ tailBoundsMonotonicAfter: 0,
152
+ };
153
+ }
154
+
155
+ if (regime === 'critical') {
156
+ const a = y0;
157
+ const b = v0 + omega0 * y0;
158
+ const velocityConstant = b - omega0 * a;
159
+ const velocityLinear = -omega0 * b;
160
+
161
+ for (const [name, value] of [
162
+ ['positionCoefficient', b],
163
+ ['velocityConstant', velocityConstant],
164
+ ['velocityLinear', velocityLinear],
165
+ ] as const) {
166
+ assertFinite(name, value);
167
+ }
168
+
169
+ return {
170
+ angularFrequency: omega0,
171
+ dampingRatio: zeta,
172
+ regime,
173
+ stateAt(time) {
174
+ assertNonNegativeTime(time);
175
+ if (time === 0) {
176
+ return { position: initialPosition, velocity: initialVelocity };
177
+ }
178
+ const decay = Math.exp(-omega0 * time);
179
+ if (decay === 0) return { position: target, velocity: 0 };
180
+ const timeDecay = time * decay;
181
+ return finiteState(
182
+ target + decay * a + timeDecay * b,
183
+ decay * velocityConstant + timeDecay * velocityLinear,
184
+ );
185
+ },
186
+ tailBoundsAt(time) {
187
+ assertNonNegativeTime(time);
188
+ const decay = Math.exp(-omega0 * time);
189
+ if (decay === 0) return { position: 0, velocity: 0 };
190
+ const timeDecay = time * decay;
191
+ return finiteState(
192
+ decay * Math.abs(a) + timeDecay * Math.abs(b),
193
+ decay * Math.abs(velocityConstant) +
194
+ timeDecay * Math.abs(velocityLinear),
195
+ ' bound',
196
+ );
197
+ },
198
+ // Every t·exp(-ωt) term decreases after t = 1/ω.
199
+ tailBoundsMonotonicAfter: 1 / omega0,
200
+ };
201
+ }
202
+
203
+ const normalizedFrequency = omega0 / alpha;
204
+ const normalizedOffset = Math.sqrt(
205
+ Math.max(0, (1 - normalizedFrequency) * (1 + normalizedFrequency)),
206
+ );
207
+ const rootFastMagnitude = alpha * (1 + normalizedOffset);
208
+ const rootFast = -rootFastMagnitude;
209
+ // Vieta's relation avoids subtracting two almost equal values in
210
+ // -alpha + sqrt(alpha² - omega0²).
211
+ const rootSlow = -omega0 * (omega0 / rootFastMagnitude);
212
+ const rootSeparation = rootSlow - rootFast;
213
+ const normalizedVelocity = v0 / rootSeparation;
214
+ const coefficientSlow =
215
+ normalizedVelocity - multiplyDivide(rootFast, y0, rootSeparation);
216
+ const coefficientFast =
217
+ multiplyDivide(rootSlow, y0, rootSeparation) - normalizedVelocity;
218
+
219
+ for (const [name, value] of [
220
+ ['overdampedSlowRoot', rootSlow],
221
+ ['overdampedFastRoot', rootFast],
222
+ ['overdampedRootSeparation', rootSeparation],
223
+ ['slowModeCoefficient', coefficientSlow],
224
+ ['fastModeCoefficient', coefficientFast],
225
+ ] as const) {
226
+ assertFinite(name, value);
227
+ }
228
+ if (rootSlow >= 0 || rootFast >= 0 || rootSeparation <= 0) {
229
+ throw new RangeError('overdamped roots must be distinct negative finite numbers');
230
+ }
231
+
232
+ return {
233
+ angularFrequency: omega0,
234
+ dampingRatio: zeta,
235
+ regime,
236
+ stateAt(time) {
237
+ assertNonNegativeTime(time);
238
+ if (time === 0) {
239
+ return { position: initialPosition, velocity: initialVelocity };
240
+ }
241
+ const slow = coefficientSlow * Math.exp(rootSlow * time);
242
+ const fast = coefficientFast * Math.exp(rootFast * time);
243
+ return finiteState(
244
+ target + slow + fast,
245
+ rootSlow * slow + rootFast * fast,
246
+ );
247
+ },
248
+ tailBoundsAt(time) {
249
+ assertNonNegativeTime(time);
250
+ const slowDecay = Math.exp(rootSlow * time);
251
+ const fastDecay = Math.exp(rootFast * time);
252
+ return finiteState(
253
+ Math.abs(coefficientSlow) * slowDecay +
254
+ Math.abs(coefficientFast) * fastDecay,
255
+ Math.abs(rootSlow * coefficientSlow) * slowDecay +
256
+ Math.abs(rootFast * coefficientFast) * fastDecay,
257
+ ' bound',
258
+ );
259
+ },
260
+ tailBoundsMonotonicAfter: 0,
261
+ };
262
+ }