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.
- package/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/README.md +340 -0
- package/dist/coupled-system.d.ts +50 -0
- package/dist/coupled-system.d.ts.map +1 -0
- package/dist/coupled-system.js +269 -0
- package/dist/coupled-system.js.map +1 -0
- package/dist/coupled.d.ts +3 -0
- package/dist/coupled.d.ts.map +1 -0
- package/dist/coupled.js +2 -0
- package/dist/coupled.js.map +1 -0
- package/dist/css-linear.d.ts +20 -0
- package/dist/css-linear.d.ts.map +1 -0
- package/dist/css-linear.js +95 -0
- package/dist/css-linear.js.map +1 -0
- package/dist/css.d.ts +3 -0
- package/dist/css.d.ts.map +1 -0
- package/dist/css.js +2 -0
- package/dist/css.js.map +1 -0
- package/dist/gsap/active-tracks.d.ts +32 -0
- package/dist/gsap/active-tracks.d.ts.map +1 -0
- package/dist/gsap/active-tracks.js +177 -0
- package/dist/gsap/active-tracks.js.map +1 -0
- package/dist/gsap/gsap-time.d.ts +10 -0
- package/dist/gsap/gsap-time.d.ts.map +1 -0
- package/dist/gsap/gsap-time.js +62 -0
- package/dist/gsap/gsap-time.js.map +1 -0
- package/dist/gsap/plugin-tween-coordinator.d.ts +30 -0
- package/dist/gsap/plugin-tween-coordinator.d.ts.map +1 -0
- package/dist/gsap/plugin-tween-coordinator.js +72 -0
- package/dist/gsap/plugin-tween-coordinator.js.map +1 -0
- package/dist/gsap/plugin.d.ts +84 -0
- package/dist/gsap/plugin.d.ts.map +1 -0
- package/dist/gsap/plugin.js +552 -0
- package/dist/gsap/plugin.js.map +1 -0
- package/dist/gsap/spring-to.d.ts +93 -0
- package/dist/gsap/spring-to.d.ts.map +1 -0
- package/dist/gsap/spring-to.js +569 -0
- package/dist/gsap/spring-to.js.map +1 -0
- package/dist/gsap/spring-track-policy.d.ts +24 -0
- package/dist/gsap/spring-track-policy.d.ts.map +1 -0
- package/dist/gsap/spring-track-policy.js +76 -0
- package/dist/gsap/spring-track-policy.js.map +1 -0
- package/dist/gsap/track-lifecycle.d.ts +16 -0
- package/dist/gsap/track-lifecycle.d.ts.map +1 -0
- package/dist/gsap/track-lifecycle.js +33 -0
- package/dist/gsap/track-lifecycle.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/math.d.ts +9 -0
- package/dist/math.d.ts.map +1 -0
- package/dist/math.js +38 -0
- package/dist/math.js.map +1 -0
- package/dist/model.d.ts +14 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +38 -0
- package/dist/model.js.map +1 -0
- package/dist/motion-compatibility.d.ts +18 -0
- package/dist/motion-compatibility.d.ts.map +1 -0
- package/dist/motion-compatibility.js +83 -0
- package/dist/motion-compatibility.js.map +1 -0
- package/dist/parameters.d.ts +52 -0
- package/dist/parameters.d.ts.map +1 -0
- package/dist/parameters.js +124 -0
- package/dist/parameters.js.map +1 -0
- package/dist/settling.d.ts +12 -0
- package/dist/settling.d.ts.map +1 -0
- package/dist/settling.js +107 -0
- package/dist/settling.js.map +1 -0
- package/dist/solver.d.ts +17 -0
- package/dist/solver.d.ts.map +1 -0
- package/dist/solver.js +190 -0
- package/dist/solver.js.map +1 -0
- package/dist/spring.d.ts +9 -0
- package/dist/spring.d.ts.map +1 -0
- package/dist/spring.js +91 -0
- package/dist/spring.js.map +1 -0
- package/dist/support/additive-spring-value.d.ts +39 -0
- package/dist/support/additive-spring-value.d.ts.map +1 -0
- package/dist/support/additive-spring-value.js +281 -0
- package/dist/support/additive-spring-value.js.map +1 -0
- package/dist/support/inertia.d.ts +34 -0
- package/dist/support/inertia.d.ts.map +1 -0
- package/dist/support/inertia.js +293 -0
- package/dist/support/inertia.js.map +1 -0
- package/dist/support/keyframes.d.ts +34 -0
- package/dist/support/keyframes.d.ts.map +1 -0
- package/dist/support/keyframes.js +117 -0
- package/dist/support/keyframes.js.map +1 -0
- package/dist/support/spring-value.d.ts +38 -0
- package/dist/support/spring-value.d.ts.map +1 -0
- package/dist/support/spring-value.js +289 -0
- package/dist/support/spring-value.js.map +1 -0
- package/dist/support/velocity.d.ts +14 -0
- package/dist/support/velocity.d.ts.map +1 -0
- package/dist/support/velocity.js +75 -0
- package/dist/support/velocity.js.map +1 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/vector.d.ts +32 -0
- package/dist/vector.d.ts.map +1 -0
- package/dist/vector.js +121 -0
- package/dist/vector.js.map +1 -0
- package/package.json +68 -0
- package/src/coupled-system.ts +383 -0
- package/src/coupled.ts +10 -0
- package/src/css-linear.ts +148 -0
- package/src/css.ts +6 -0
- package/src/gsap/active-tracks.ts +244 -0
- package/src/gsap/gsap-time.ts +91 -0
- package/src/gsap/plugin-tween-coordinator.ts +112 -0
- package/src/gsap/plugin.ts +861 -0
- package/src/gsap/spring-to.ts +836 -0
- package/src/gsap/spring-track-policy.ts +133 -0
- package/src/gsap/track-lifecycle.ts +53 -0
- package/src/index.ts +130 -0
- package/src/math.ts +46 -0
- package/src/model.ts +70 -0
- package/src/motion-compatibility.ts +131 -0
- package/src/parameters.ts +226 -0
- package/src/settling.ts +141 -0
- package/src/solver.ts +262 -0
- package/src/spring.ts +119 -0
- package/src/support/additive-spring-value.ts +343 -0
- package/src/support/inertia.ts +367 -0
- package/src/support/keyframes.ts +188 -0
- package/src/support/spring-value.ts +399 -0
- package/src/support/velocity.ts +119 -0
- package/src/types.ts +70 -0
- 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
|
+
});
|
package/src/settling.ts
ADDED
|
@@ -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
|
+
}
|