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,383 @@
|
|
|
1
|
+
export interface CoupledAnchor {
|
|
2
|
+
target: number;
|
|
3
|
+
stiffness: number;
|
|
4
|
+
damping: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface CoupledParticle {
|
|
8
|
+
mass: number;
|
|
9
|
+
position: number;
|
|
10
|
+
velocity?: number;
|
|
11
|
+
fixed?: boolean;
|
|
12
|
+
anchor?: CoupledAnchor;
|
|
13
|
+
min?: number;
|
|
14
|
+
max?: number;
|
|
15
|
+
restitution?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SpringConnection {
|
|
19
|
+
from: number;
|
|
20
|
+
to: number;
|
|
21
|
+
stiffness: number;
|
|
22
|
+
damping: number;
|
|
23
|
+
restOffset?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CoupledSpringOptions {
|
|
27
|
+
particles: readonly CoupledParticle[];
|
|
28
|
+
connections: readonly SpringConnection[];
|
|
29
|
+
maxStep?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CoupledSpringState {
|
|
33
|
+
position: readonly number[];
|
|
34
|
+
velocity: readonly number[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface MutableCoupledSpringState {
|
|
38
|
+
position: number[];
|
|
39
|
+
velocity: number[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CoupledSpringSystem {
|
|
43
|
+
readonly dimension: number;
|
|
44
|
+
readonly particles: readonly Readonly<CoupledParticle>[];
|
|
45
|
+
readonly connections: readonly Readonly<SpringConnection>[];
|
|
46
|
+
readonly initialState: Readonly<CoupledSpringState>;
|
|
47
|
+
readonly maxStep: number;
|
|
48
|
+
forceAt(state: CoupledSpringState): readonly number[];
|
|
49
|
+
accelerationAt(state: CoupledSpringState): readonly number[];
|
|
50
|
+
advance(state: CoupledSpringState, deltaTime: number): CoupledSpringState;
|
|
51
|
+
advanceInto(
|
|
52
|
+
state: CoupledSpringState,
|
|
53
|
+
deltaTime: number,
|
|
54
|
+
output: MutableCoupledSpringState,
|
|
55
|
+
): MutableCoupledSpringState;
|
|
56
|
+
stateAt(time: number): CoupledSpringState;
|
|
57
|
+
energyAt(state: CoupledSpringState): number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const MAX_INTEGRATION_STEPS = 1_000_000;
|
|
61
|
+
|
|
62
|
+
function assertFinite(name: string, value: number): void {
|
|
63
|
+
if (!Number.isFinite(value)) {
|
|
64
|
+
throw new RangeError(`${name} must be a finite number`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function assertPositive(name: string, value: number): void {
|
|
69
|
+
assertFinite(name, value);
|
|
70
|
+
if (value <= 0) throw new RangeError(`${name} must be greater than 0`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function assertNonNegative(name: string, value: number): void {
|
|
74
|
+
assertFinite(name, value);
|
|
75
|
+
if (value < 0) throw new RangeError(`${name} must be greater than or equal to 0`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createCoupledSpringSystem(
|
|
79
|
+
options: CoupledSpringOptions,
|
|
80
|
+
): CoupledSpringSystem {
|
|
81
|
+
if (options.particles.length === 0) {
|
|
82
|
+
throw new RangeError('a coupled spring system requires at least one particle');
|
|
83
|
+
}
|
|
84
|
+
const maxStep = options.maxStep === undefined ? 1 / 240 : options.maxStep;
|
|
85
|
+
assertPositive('maxStep', maxStep);
|
|
86
|
+
const particles = Object.freeze(
|
|
87
|
+
options.particles.map((particle, index) => {
|
|
88
|
+
assertPositive(`particles[${index}].mass`, particle.mass);
|
|
89
|
+
assertFinite(`particles[${index}].position`, particle.position);
|
|
90
|
+
const velocity = particle.velocity === undefined ? 0 : particle.velocity;
|
|
91
|
+
assertFinite(`particles[${index}].velocity`, velocity);
|
|
92
|
+
if (particle.min !== undefined) {
|
|
93
|
+
assertFinite(`particles[${index}].min`, particle.min);
|
|
94
|
+
}
|
|
95
|
+
if (particle.max !== undefined) {
|
|
96
|
+
assertFinite(`particles[${index}].max`, particle.max);
|
|
97
|
+
}
|
|
98
|
+
if (
|
|
99
|
+
particle.min !== undefined &&
|
|
100
|
+
particle.max !== undefined &&
|
|
101
|
+
particle.min > particle.max
|
|
102
|
+
) {
|
|
103
|
+
throw new RangeError(`particles[${index}].min must not exceed max`);
|
|
104
|
+
}
|
|
105
|
+
const restitution =
|
|
106
|
+
particle.restitution === undefined ? 0 : particle.restitution;
|
|
107
|
+
assertFinite(`particles[${index}].restitution`, restitution);
|
|
108
|
+
if (restitution < 0 || restitution > 1) {
|
|
109
|
+
throw new RangeError(`particles[${index}].restitution must be between 0 and 1`);
|
|
110
|
+
}
|
|
111
|
+
if (particle.anchor) {
|
|
112
|
+
assertFinite(`particles[${index}].anchor.target`, particle.anchor.target);
|
|
113
|
+
assertPositive(
|
|
114
|
+
`particles[${index}].anchor.stiffness`,
|
|
115
|
+
particle.anchor.stiffness,
|
|
116
|
+
);
|
|
117
|
+
assertNonNegative(
|
|
118
|
+
`particles[${index}].anchor.damping`,
|
|
119
|
+
particle.anchor.damping,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return Object.freeze({
|
|
123
|
+
...particle,
|
|
124
|
+
velocity: particle.fixed ? 0 : velocity,
|
|
125
|
+
restitution,
|
|
126
|
+
...(particle.anchor === undefined
|
|
127
|
+
? {}
|
|
128
|
+
: { anchor: Object.freeze({ ...particle.anchor }) }),
|
|
129
|
+
});
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
const dimension = particles.length;
|
|
133
|
+
const connections = Object.freeze(
|
|
134
|
+
options.connections.map((connection, index) => {
|
|
135
|
+
if (
|
|
136
|
+
!Number.isInteger(connection.from) ||
|
|
137
|
+
connection.from < 0 ||
|
|
138
|
+
connection.from >= dimension
|
|
139
|
+
) {
|
|
140
|
+
throw new RangeError(`connections[${index}].from is out of range`);
|
|
141
|
+
}
|
|
142
|
+
if (
|
|
143
|
+
!Number.isInteger(connection.to) ||
|
|
144
|
+
connection.to < 0 ||
|
|
145
|
+
connection.to >= dimension
|
|
146
|
+
) {
|
|
147
|
+
throw new RangeError(`connections[${index}].to is out of range`);
|
|
148
|
+
}
|
|
149
|
+
if (connection.from === connection.to) {
|
|
150
|
+
throw new RangeError(`connections[${index}] must join two particles`);
|
|
151
|
+
}
|
|
152
|
+
assertPositive(`connections[${index}].stiffness`, connection.stiffness);
|
|
153
|
+
assertNonNegative(`connections[${index}].damping`, connection.damping);
|
|
154
|
+
const restOffset =
|
|
155
|
+
connection.restOffset === undefined ? 0 : connection.restOffset;
|
|
156
|
+
assertFinite(`connections[${index}].restOffset`, restOffset);
|
|
157
|
+
return Object.freeze({ ...connection, restOffset });
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
const fixedPositions = particles.map((particle) => particle.position);
|
|
161
|
+
const initialState: Readonly<CoupledSpringState> = Object.freeze({
|
|
162
|
+
position: Object.freeze(particles.map((particle) => particle.position)),
|
|
163
|
+
velocity: Object.freeze(particles.map((particle) => particle.velocity)),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const validateState = (state: CoupledSpringState): void => {
|
|
167
|
+
if (state.position.length !== dimension || state.velocity.length !== dimension) {
|
|
168
|
+
throw new RangeError(`state must contain exactly ${dimension} components`);
|
|
169
|
+
}
|
|
170
|
+
for (let index = 0; index < dimension; index += 1) {
|
|
171
|
+
assertFinite(`state.position[${index}]`, state.position[index]!);
|
|
172
|
+
assertFinite(`state.velocity[${index}]`, state.velocity[index]!);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const forceAtMutable = (
|
|
177
|
+
position: readonly number[],
|
|
178
|
+
velocity: readonly number[],
|
|
179
|
+
): number[] => {
|
|
180
|
+
const force = Array<number>(dimension).fill(0);
|
|
181
|
+
for (let index = 0; index < dimension; index += 1) {
|
|
182
|
+
const anchor = particles[index]!.anchor;
|
|
183
|
+
if (!anchor) continue;
|
|
184
|
+
force[index] =
|
|
185
|
+
-anchor.stiffness * (position[index]! - anchor.target) -
|
|
186
|
+
anchor.damping * velocity[index]!;
|
|
187
|
+
}
|
|
188
|
+
for (const connection of connections) {
|
|
189
|
+
const extension =
|
|
190
|
+
position[connection.to]! -
|
|
191
|
+
position[connection.from]! -
|
|
192
|
+
connection.restOffset;
|
|
193
|
+
const relativeVelocity =
|
|
194
|
+
velocity[connection.to]! - velocity[connection.from]!;
|
|
195
|
+
const couplingForce =
|
|
196
|
+
connection.stiffness * extension + connection.damping * relativeVelocity;
|
|
197
|
+
force[connection.from]! += couplingForce;
|
|
198
|
+
force[connection.to]! -= couplingForce;
|
|
199
|
+
}
|
|
200
|
+
for (let index = 0; index < dimension; index += 1) {
|
|
201
|
+
assertFinite(`force[${index}]`, force[index]!);
|
|
202
|
+
}
|
|
203
|
+
return force;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const derivative = (
|
|
207
|
+
position: readonly number[],
|
|
208
|
+
velocity: readonly number[],
|
|
209
|
+
): { position: number[]; velocity: number[] } => {
|
|
210
|
+
const force = forceAtMutable(position, velocity);
|
|
211
|
+
return {
|
|
212
|
+
position: velocity.map((value, index) =>
|
|
213
|
+
particles[index]!.fixed ? 0 : value,
|
|
214
|
+
),
|
|
215
|
+
velocity: force.map((value, index) =>
|
|
216
|
+
particles[index]!.fixed ? 0 : value / particles[index]!.mass,
|
|
217
|
+
),
|
|
218
|
+
};
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const integrateStep = (
|
|
222
|
+
position: number[],
|
|
223
|
+
velocity: number[],
|
|
224
|
+
step: number,
|
|
225
|
+
): void => {
|
|
226
|
+
const k1 = derivative(position, velocity);
|
|
227
|
+
const p2 = position.map((value, index) => value + (k1.position[index]! * step) / 2);
|
|
228
|
+
const v2 = velocity.map((value, index) => value + (k1.velocity[index]! * step) / 2);
|
|
229
|
+
const k2 = derivative(p2, v2);
|
|
230
|
+
const p3 = position.map((value, index) => value + (k2.position[index]! * step) / 2);
|
|
231
|
+
const v3 = velocity.map((value, index) => value + (k2.velocity[index]! * step) / 2);
|
|
232
|
+
const k3 = derivative(p3, v3);
|
|
233
|
+
const p4 = position.map((value, index) => value + k3.position[index]! * step);
|
|
234
|
+
const v4 = velocity.map((value, index) => value + k3.velocity[index]! * step);
|
|
235
|
+
const k4 = derivative(p4, v4);
|
|
236
|
+
|
|
237
|
+
for (let index = 0; index < dimension; index += 1) {
|
|
238
|
+
const particle = particles[index]!;
|
|
239
|
+
if (particle.fixed) {
|
|
240
|
+
position[index] = fixedPositions[index]!;
|
|
241
|
+
velocity[index] = 0;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
position[index] =
|
|
245
|
+
position[index]! +
|
|
246
|
+
(step / 6) *
|
|
247
|
+
(k1.position[index]! +
|
|
248
|
+
2 * k2.position[index]! +
|
|
249
|
+
2 * k3.position[index]! +
|
|
250
|
+
k4.position[index]!);
|
|
251
|
+
velocity[index] =
|
|
252
|
+
velocity[index]! +
|
|
253
|
+
(step / 6) *
|
|
254
|
+
(k1.velocity[index]! +
|
|
255
|
+
2 * k2.velocity[index]! +
|
|
256
|
+
2 * k3.velocity[index]! +
|
|
257
|
+
k4.velocity[index]!);
|
|
258
|
+
|
|
259
|
+
assertFinite(`position[${index}]`, position[index]!);
|
|
260
|
+
assertFinite(`velocity[${index}]`, velocity[index]!);
|
|
261
|
+
|
|
262
|
+
if (particle.min !== undefined && position[index]! < particle.min) {
|
|
263
|
+
position[index] = particle.min;
|
|
264
|
+
if (velocity[index]! < 0) velocity[index] = -velocity[index]! * particle.restitution;
|
|
265
|
+
}
|
|
266
|
+
if (particle.max !== undefined && position[index]! > particle.max) {
|
|
267
|
+
position[index] = particle.max;
|
|
268
|
+
if (velocity[index]! > 0) velocity[index] = -velocity[index]! * particle.restitution;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const advanceInto = (
|
|
274
|
+
state: CoupledSpringState,
|
|
275
|
+
deltaTime: number,
|
|
276
|
+
output: MutableCoupledSpringState,
|
|
277
|
+
): MutableCoupledSpringState => {
|
|
278
|
+
validateState(state);
|
|
279
|
+
assertFinite('deltaTime', deltaTime);
|
|
280
|
+
if (deltaTime < 0) {
|
|
281
|
+
throw new RangeError('deltaTime must be greater than or equal to 0');
|
|
282
|
+
}
|
|
283
|
+
if (output.position.length < dimension || output.velocity.length < dimension) {
|
|
284
|
+
throw new RangeError(`output buffers must contain at least ${dimension} components`);
|
|
285
|
+
}
|
|
286
|
+
if (deltaTime === 0) {
|
|
287
|
+
for (let index = 0; index < dimension; index += 1) {
|
|
288
|
+
output.position[index] = state.position[index]!;
|
|
289
|
+
output.velocity[index] = state.velocity[index]!;
|
|
290
|
+
}
|
|
291
|
+
return output;
|
|
292
|
+
}
|
|
293
|
+
const position = Array.from(state.position);
|
|
294
|
+
const velocity = Array.from(state.velocity);
|
|
295
|
+
const steps = Math.max(1, Math.ceil(deltaTime / maxStep));
|
|
296
|
+
if (!Number.isSafeInteger(steps) || steps > MAX_INTEGRATION_STEPS) {
|
|
297
|
+
throw new RangeError(
|
|
298
|
+
`advance requires ${steps} integration steps; increase maxStep or split the simulation into smaller calls`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
const step = deltaTime / steps;
|
|
302
|
+
for (let index = 0; index < steps; index += 1) {
|
|
303
|
+
integrateStep(position, velocity, step);
|
|
304
|
+
}
|
|
305
|
+
for (let index = 0; index < dimension; index += 1) {
|
|
306
|
+
output.position[index] = position[index]!;
|
|
307
|
+
output.velocity[index] = velocity[index]!;
|
|
308
|
+
}
|
|
309
|
+
return output;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const advance = (
|
|
313
|
+
state: CoupledSpringState,
|
|
314
|
+
deltaTime: number,
|
|
315
|
+
): CoupledSpringState => {
|
|
316
|
+
const output = advanceInto(state, deltaTime, {
|
|
317
|
+
position: Array<number>(dimension),
|
|
318
|
+
velocity: Array<number>(dimension),
|
|
319
|
+
});
|
|
320
|
+
return Object.freeze({
|
|
321
|
+
position: Object.freeze(output.position),
|
|
322
|
+
velocity: Object.freeze(output.velocity),
|
|
323
|
+
});
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
return Object.freeze({
|
|
327
|
+
dimension,
|
|
328
|
+
particles,
|
|
329
|
+
connections,
|
|
330
|
+
initialState,
|
|
331
|
+
maxStep,
|
|
332
|
+
forceAt(state: CoupledSpringState): readonly number[] {
|
|
333
|
+
validateState(state);
|
|
334
|
+
return Object.freeze(forceAtMutable(state.position, state.velocity));
|
|
335
|
+
},
|
|
336
|
+
accelerationAt(state: CoupledSpringState): readonly number[] {
|
|
337
|
+
validateState(state);
|
|
338
|
+
const force = forceAtMutable(state.position, state.velocity);
|
|
339
|
+
return Object.freeze(
|
|
340
|
+
force.map((value, index) => {
|
|
341
|
+
const acceleration = particles[index]!.fixed
|
|
342
|
+
? 0
|
|
343
|
+
: value / particles[index]!.mass;
|
|
344
|
+
assertFinite(`acceleration[${index}]`, acceleration);
|
|
345
|
+
return acceleration;
|
|
346
|
+
}),
|
|
347
|
+
);
|
|
348
|
+
},
|
|
349
|
+
advance,
|
|
350
|
+
advanceInto,
|
|
351
|
+
stateAt(time: number): CoupledSpringState {
|
|
352
|
+
assertFinite('time', time);
|
|
353
|
+
if (time < 0) throw new RangeError('time must be greater than or equal to 0');
|
|
354
|
+
if (time === 0) return initialState;
|
|
355
|
+
return advance(initialState, time);
|
|
356
|
+
},
|
|
357
|
+
energyAt(state: CoupledSpringState): number {
|
|
358
|
+
validateState(state);
|
|
359
|
+
let energy = 0;
|
|
360
|
+
for (let index = 0; index < dimension; index += 1) {
|
|
361
|
+
const particle = particles[index]!;
|
|
362
|
+
if (!particle.fixed) {
|
|
363
|
+
energy += (particle.mass * state.velocity[index]! ** 2) / 2;
|
|
364
|
+
}
|
|
365
|
+
if (particle.anchor) {
|
|
366
|
+
energy +=
|
|
367
|
+
(particle.anchor.stiffness *
|
|
368
|
+
(state.position[index]! - particle.anchor.target) ** 2) /
|
|
369
|
+
2;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
for (const connection of connections) {
|
|
373
|
+
const extension =
|
|
374
|
+
state.position[connection.to]! -
|
|
375
|
+
state.position[connection.from]! -
|
|
376
|
+
connection.restOffset;
|
|
377
|
+
energy += (connection.stiffness * extension ** 2) / 2;
|
|
378
|
+
}
|
|
379
|
+
assertFinite('energy', energy);
|
|
380
|
+
return energy;
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
}
|
package/src/coupled.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { createCoupledSpringSystem } from './coupled-system.js';
|
|
2
|
+
export type {
|
|
3
|
+
CoupledAnchor,
|
|
4
|
+
CoupledParticle,
|
|
5
|
+
CoupledSpringOptions,
|
|
6
|
+
CoupledSpringState,
|
|
7
|
+
CoupledSpringSystem,
|
|
8
|
+
MutableCoupledSpringState,
|
|
9
|
+
SpringConnection,
|
|
10
|
+
} from './coupled-system.js';
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { SpringSolution } from './types.js';
|
|
2
|
+
|
|
3
|
+
export interface CSSLinearSpringOptions {
|
|
4
|
+
duration?: number;
|
|
5
|
+
maxError?: number;
|
|
6
|
+
maxDepth?: number;
|
|
7
|
+
maxSamples?: number;
|
|
8
|
+
precision?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface CSSLinearSample {
|
|
12
|
+
time: number;
|
|
13
|
+
progress: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CSSLinearSpring {
|
|
17
|
+
easing: string;
|
|
18
|
+
duration: number;
|
|
19
|
+
maxError: number;
|
|
20
|
+
samples: readonly Readonly<CSSLinearSample>[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function assertPositive(name: string, value: number): void {
|
|
24
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
25
|
+
throw new RangeError(`${name} must be a finite number greater than 0`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function assertPositiveInteger(name: string, value: number): void {
|
|
30
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
31
|
+
throw new RangeError(`${name} must be a positive integer`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function rounded(value: number, precision: number): string {
|
|
36
|
+
const result = Number(value.toFixed(precision));
|
|
37
|
+
return Object.is(result, -0) ? '0' : String(result);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function springToCSSLinear(
|
|
41
|
+
spring: SpringSolution,
|
|
42
|
+
options: CSSLinearSpringOptions = {},
|
|
43
|
+
): CSSLinearSpring {
|
|
44
|
+
if (typeof options !== 'object' || options === null || Array.isArray(options)) {
|
|
45
|
+
throw new TypeError('CSS linear options must be an object when provided');
|
|
46
|
+
}
|
|
47
|
+
const duration =
|
|
48
|
+
options.duration === undefined ? spring.timing.settlingDuration : options.duration;
|
|
49
|
+
const maxError = options.maxError === undefined ? 0.001 : options.maxError;
|
|
50
|
+
const maxDepth = options.maxDepth === undefined ? 12 : options.maxDepth;
|
|
51
|
+
const maxSamples = options.maxSamples === undefined ? 4096 : options.maxSamples;
|
|
52
|
+
const precision = options.precision === undefined ? 6 : options.precision;
|
|
53
|
+
assertPositive('duration', duration);
|
|
54
|
+
assertPositive('maxError', maxError);
|
|
55
|
+
assertPositiveInteger('maxDepth', maxDepth);
|
|
56
|
+
assertPositiveInteger('maxSamples', maxSamples);
|
|
57
|
+
if (!Number.isInteger(precision) || precision < 0 || precision > 15) {
|
|
58
|
+
throw new RangeError('precision must be an integer between 0 and 15');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const distance = spring.initialState.target - spring.initialState.position;
|
|
62
|
+
if (!Number.isFinite(distance)) {
|
|
63
|
+
throw new RangeError('CSS spring animation distance must be a finite number');
|
|
64
|
+
}
|
|
65
|
+
if (distance === 0) {
|
|
66
|
+
throw new RangeError('CSS spring export requires a non-zero animation distance');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const progressAt = (time: number): number => {
|
|
70
|
+
if (spring.timing.settled && time >= duration) return 1;
|
|
71
|
+
const progress =
|
|
72
|
+
(spring.positionAt(time) - spring.initialState.position) / distance;
|
|
73
|
+
if (!Number.isFinite(progress)) {
|
|
74
|
+
throw new RangeError('normalized progress must be a finite number');
|
|
75
|
+
}
|
|
76
|
+
return progress;
|
|
77
|
+
};
|
|
78
|
+
const samples: CSSLinearSample[] = [
|
|
79
|
+
{ time: 0, progress: progressAt(0) },
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const append = (sample: CSSLinearSample): void => {
|
|
83
|
+
if (samples.length >= maxSamples) {
|
|
84
|
+
throw new RangeError(`CSS linear export exceeded maxSamples (${maxSamples})`);
|
|
85
|
+
}
|
|
86
|
+
samples.push(sample);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const refine = (
|
|
90
|
+
start: CSSLinearSample,
|
|
91
|
+
end: CSSLinearSample,
|
|
92
|
+
depth: number,
|
|
93
|
+
): void => {
|
|
94
|
+
const span = end.time - start.time;
|
|
95
|
+
const checkpoints = [0.25, 0.5, 0.75].map((fraction) => {
|
|
96
|
+
const time = start.time + span * fraction;
|
|
97
|
+
const progress = progressAt(time);
|
|
98
|
+
const linear = start.progress + (end.progress - start.progress) * fraction;
|
|
99
|
+
return { deviation: Math.abs(progress - linear), time, progress };
|
|
100
|
+
});
|
|
101
|
+
const needsRefinement = checkpoints.some(
|
|
102
|
+
({ deviation }) => deviation > maxError,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
if (needsRefinement && depth < maxDepth) {
|
|
106
|
+
const midpoint = checkpoints[1]!;
|
|
107
|
+
if (midpoint.time === start.time || midpoint.time === end.time) {
|
|
108
|
+
append(end);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const middle = { time: midpoint.time, progress: midpoint.progress };
|
|
112
|
+
refine(start, middle, depth + 1);
|
|
113
|
+
refine(middle, end, depth + 1);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
append(end);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Quarter-period seeds prevent a high-frequency oscillation from aliasing to
|
|
120
|
+
// deceptively straight midpoint samples before adaptive refinement begins.
|
|
121
|
+
const seedCount = Math.max(
|
|
122
|
+
1,
|
|
123
|
+
Math.ceil((duration * spring.angularFrequency) / (Math.PI / 2)),
|
|
124
|
+
);
|
|
125
|
+
if (seedCount + 1 > maxSamples) {
|
|
126
|
+
throw new RangeError(`CSS linear export requires more than maxSamples (${maxSamples})`);
|
|
127
|
+
}
|
|
128
|
+
for (let index = 0; index < seedCount; index += 1) {
|
|
129
|
+
const start = samples.at(-1)!;
|
|
130
|
+
const time = ((index + 1) / seedCount) * duration;
|
|
131
|
+
refine(start, { time, progress: progressAt(time) }, 0);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const frozenSamples = Object.freeze(
|
|
135
|
+
samples.map((sample) => Object.freeze({ ...sample })),
|
|
136
|
+
);
|
|
137
|
+
const easing = `linear(${frozenSamples
|
|
138
|
+
.map(
|
|
139
|
+
(sample) =>
|
|
140
|
+
`${rounded(sample.progress, precision)} ${rounded(
|
|
141
|
+
(sample.time / duration) * 100,
|
|
142
|
+
precision,
|
|
143
|
+
)}%`,
|
|
144
|
+
)
|
|
145
|
+
.join(', ')})`;
|
|
146
|
+
|
|
147
|
+
return Object.freeze({ easing, duration, maxError, samples: frozenSamples });
|
|
148
|
+
}
|