coily 0.14.0 → 0.15.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/README.md +9 -3
- package/dist/config-OOMMRdK8.d.ts +312 -0
- package/dist/css.d.ts +175 -0
- package/dist/css.js +294 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +16 -3
- package/dist/nuxt.d.ts +8 -0
- package/dist/nuxt.js +1 -0
- package/dist/solver-CVXOTDkZ.js +235 -0
- package/dist/{system-BXxAHM2a.js → system-Br_cPnaN.js} +678 -248
- package/dist/{system-Ci-R4RaI.d.ts → system-Du_fXXmO.d.ts} +185 -277
- package/dist/vue.d.ts +2 -1
- package/dist/vue.js +1 -1
- package/package.json +2 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { a as RESOLVED, c as isNumber, i as State, l as isRecord, n as OverdampedSolver, o as invariant, r as UnderdampedSolver, s as isArray, t as CriticallyDampedSolver, u as isRecordOrArray } from "./solver-CVXOTDkZ.js";
|
|
1
2
|
//#region src/flush-queue.ts
|
|
2
3
|
/**
|
|
3
4
|
* Defers callbacks while inside a batch or tick: `request` runs the
|
|
@@ -32,90 +33,6 @@ var FlushQueue = class {
|
|
|
32
33
|
}
|
|
33
34
|
};
|
|
34
35
|
//#endregion
|
|
35
|
-
//#region src/motion-set.ts
|
|
36
|
-
/**
|
|
37
|
-
* The set of motions currently moving. Motions leave the set as they
|
|
38
|
-
* rest and re-enter on any write that disturbs them; `onWake` tells the
|
|
39
|
-
* ticker the set became non-empty so it can stop sleeping.
|
|
40
|
-
*/
|
|
41
|
-
var MotionSet = class {
|
|
42
|
-
reduced = false;
|
|
43
|
-
onWake = null;
|
|
44
|
-
flushes = new FlushQueue();
|
|
45
|
-
#motions = /* @__PURE__ */ new Set();
|
|
46
|
-
#debug;
|
|
47
|
-
#lastSize = 0;
|
|
48
|
-
#pass = 0;
|
|
49
|
-
constructor(debug = false) {
|
|
50
|
-
this.#debug = debug;
|
|
51
|
-
}
|
|
52
|
-
get size() {
|
|
53
|
-
return this.#motions.size;
|
|
54
|
-
}
|
|
55
|
-
add(motion) {
|
|
56
|
-
const wasEmpty = this.#motions.size === 0;
|
|
57
|
-
this.#motions.add(motion);
|
|
58
|
-
if (wasEmpty) this.onWake?.();
|
|
59
|
-
}
|
|
60
|
-
remove(motion) {
|
|
61
|
-
this.#motions.delete(motion);
|
|
62
|
-
}
|
|
63
|
-
finishAll() {
|
|
64
|
-
this.flushes.batch(() => {
|
|
65
|
-
for (const motion of this.#motions) {
|
|
66
|
-
if (!motion.respectsReducedMotion) continue;
|
|
67
|
-
motion.finish();
|
|
68
|
-
this.#motions.delete(motion);
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
tick(dt) {
|
|
73
|
-
this.#pass++;
|
|
74
|
-
this.flushes.enter();
|
|
75
|
-
try {
|
|
76
|
-
for (const motion of this.#motions) {
|
|
77
|
-
if (motion._pass === this.#pass) continue;
|
|
78
|
-
motion._pass = this.#pass;
|
|
79
|
-
motion.tick(dt);
|
|
80
|
-
if (motion.isResting) this.#motions.delete(motion);
|
|
81
|
-
}
|
|
82
|
-
} finally {
|
|
83
|
-
this.flushes.exit();
|
|
84
|
-
}
|
|
85
|
-
if (this.#debug && this.#motions.size !== this.#lastSize) {
|
|
86
|
-
this.#lastSize = this.#motions.size;
|
|
87
|
-
console.log(`coily: ${this.#lastSize} active motions`);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
};
|
|
91
|
-
//#endregion
|
|
92
|
-
//#region src/util.ts
|
|
93
|
-
function invariant(condition, message) {
|
|
94
|
-
if (condition) return;
|
|
95
|
-
const resolved = typeof message === "function" ? message() : message;
|
|
96
|
-
throw new Error(resolved ?? "Invariant Failed");
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
|
-
* True for plain objects only. Class instances are excluded so shape
|
|
100
|
-
* walks reject values like a `Spring` instead of descending into their
|
|
101
|
-
* fields as if they were nested shapes.
|
|
102
|
-
*/
|
|
103
|
-
function isRecord(value) {
|
|
104
|
-
if (typeof value !== "object" || value === null) return false;
|
|
105
|
-
const proto = Object.getPrototypeOf(value);
|
|
106
|
-
return proto === Object.prototype || proto === null;
|
|
107
|
-
}
|
|
108
|
-
function isRecordOrArray(value) {
|
|
109
|
-
return isRecord(value) || Array.isArray(value);
|
|
110
|
-
}
|
|
111
|
-
function isArray(value) {
|
|
112
|
-
return Array.isArray(value);
|
|
113
|
-
}
|
|
114
|
-
function isNumber(value) {
|
|
115
|
-
return typeof value === "number";
|
|
116
|
-
}
|
|
117
|
-
const RESOLVED = Promise.resolve();
|
|
118
|
-
//#endregion
|
|
119
36
|
//#region src/config.ts
|
|
120
37
|
/**
|
|
121
38
|
* Immutable, resolved spring parameters. Whatever `defineSpring` input
|
|
@@ -138,6 +55,13 @@ var SpringDefinition = class SpringDefinition {
|
|
|
138
55
|
damping;
|
|
139
56
|
/** Decimal places of the resting threshold — see `restingMagnitude`. */
|
|
140
57
|
precision;
|
|
58
|
+
/**
|
|
59
|
+
* Velocity multiplier applied when the value crosses the target, as
|
|
60
|
+
* provided or resolved from its named form. 1 (`'passthrough'`) leaves
|
|
61
|
+
* the motion untouched, 0 (`'stop'`) ends it at the target, negative
|
|
62
|
+
* values rebound.
|
|
63
|
+
*/
|
|
64
|
+
arrival;
|
|
141
65
|
/** Oscillation rate with no damping: `sqrt(tension / mass)`, in radians per second. */
|
|
142
66
|
naturalFrequency;
|
|
143
67
|
/** The damping at which bounce disappears: `2 * mass * naturalFrequency`. */
|
|
@@ -159,7 +83,9 @@ var SpringDefinition = class SpringDefinition {
|
|
|
159
83
|
const duration = raw.duration !== void 0 ? raw.duration / 1e3 : void 0;
|
|
160
84
|
const displacement = raw.displacement ?? 1;
|
|
161
85
|
const precision = raw.precision ?? 2;
|
|
86
|
+
const arrival = raw.arrival;
|
|
162
87
|
for (const key of Object.keys(raw)) {
|
|
88
|
+
if (key === "arrival") continue;
|
|
163
89
|
const value = raw[key];
|
|
164
90
|
if (value !== void 0) invariant(Number.isFinite(value), () => `Invalid ${key}: expected a finite number`);
|
|
165
91
|
}
|
|
@@ -173,8 +99,12 @@ var SpringDefinition = class SpringDefinition {
|
|
|
173
99
|
if (duration !== void 0) invariant(duration > 0, "Duration must be greater than 0");
|
|
174
100
|
invariant(displacement !== 0, "Displacement must not be 0");
|
|
175
101
|
invariant(precision >= 0, "Precision must be greater than or equal to 0");
|
|
102
|
+
invariant(arrival === void 0 || arrival === "passthrough" || arrival === "stop" || typeof arrival === "number" && Number.isFinite(arrival) && arrival >= -1 && arrival <= 1, "Arrival must be 'passthrough', 'stop', or a number between -1 and 1");
|
|
176
103
|
this.precision = precision;
|
|
177
104
|
this.restingMagnitude = .5 / 10 ** precision;
|
|
105
|
+
if (arrival === void 0 || arrival === "passthrough") this.arrival = 1;
|
|
106
|
+
else if (arrival === "stop") this.arrival = 0;
|
|
107
|
+
else this.arrival = arrival === 0 ? 0 : arrival;
|
|
178
108
|
const hasM = mass !== void 0;
|
|
179
109
|
const hasK = tension !== void 0;
|
|
180
110
|
const hasC = damping !== void 0;
|
|
@@ -203,12 +133,8 @@ var SpringDefinition = class SpringDefinition {
|
|
|
203
133
|
const threshold = .005;
|
|
204
134
|
const x0 = Math.abs(displacement);
|
|
205
135
|
invariant(x0 > threshold, "displacement must be greater than the precision threshold");
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
else if (dampingRatio === 1) sigmaCoeff = 1;
|
|
209
|
-
else sigmaCoeff = dampingRatio - Math.sqrt(dampingRatio ** 2 - 1);
|
|
210
|
-
invariant(sigmaCoeff > 0, "dampingRatio must be > 0 for duration-based config");
|
|
211
|
-
const wn = 2 * Math.log(x0 / threshold) / (sigmaCoeff * duration);
|
|
136
|
+
invariant(dampingRatio > 0, "dampingRatio must be > 0 for duration-based config");
|
|
137
|
+
const wn = restUnits(dampingRatio, x0 / threshold) / duration;
|
|
212
138
|
if (!hasK && !hasC) {
|
|
213
139
|
this.mass = mass ?? 1;
|
|
214
140
|
this.tension = wn ** 2 * this.mass;
|
|
@@ -231,26 +157,99 @@ var SpringDefinition = class SpringDefinition {
|
|
|
231
157
|
Object.freeze(this);
|
|
232
158
|
}
|
|
233
159
|
/**
|
|
234
|
-
*
|
|
235
|
-
*
|
|
160
|
+
* Computes when a spring with this config comes to rest from `state`,
|
|
161
|
+
* in ms.
|
|
236
162
|
*
|
|
237
|
-
* The
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
163
|
+
* The time is solved from the motion's decay bound rather than
|
|
164
|
+
* estimated: the spring is resting at the first tick at or after it.
|
|
165
|
+
* An underdamped spring can also rest earlier — its rest amplitude
|
|
166
|
+
* pulses, and a frame that samples a dip inside the resting threshold
|
|
167
|
+
* rests there — so read the value as "no later than", tight in
|
|
168
|
+
* practice. See
|
|
169
|
+
* https://github.com/tkofh/coily/blob/main/PRECISION.md.
|
|
170
|
+
*
|
|
171
|
+
* Returns 0 when `state` is already inside the resting threshold.
|
|
172
|
+
* Returns Infinity when the motion never settles: a `dampingRatio` of
|
|
173
|
+
* 0 whose `arrival` neither stops nor slows it. When `arrival` is 0
|
|
174
|
+
* the result is capped at the first target crossing, where such a
|
|
175
|
+
* spring rests exactly; other multipliers fold their per-crossing
|
|
176
|
+
* velocity loss into the time.
|
|
241
177
|
*/
|
|
242
178
|
computeTimeRemaining(state) {
|
|
243
179
|
const { position, velocity } = state;
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
if (this.dampingRatio < 1) sigma = this.dampingRatio * this.naturalFrequency;
|
|
248
|
-
else if (this.dampingRatio === 1) sigma = this.naturalFrequency;
|
|
249
|
-
else sigma = (this.dampingRatio - Math.sqrt(this.dampingRatio * this.dampingRatio - 1)) * this.naturalFrequency;
|
|
250
|
-
if (sigma <= 0) return Infinity;
|
|
251
|
-
return 2 * Math.log(a0 / this.restingMagnitude) / sigma * 1e3;
|
|
180
|
+
if (Math.abs(position) + Math.abs(velocity) / this.naturalFrequency <= this.restingMagnitude) return 0;
|
|
181
|
+
const crossing = this.arrival === 0 ? firstCrossing(this, position, velocity) * 1e3 : Infinity;
|
|
182
|
+
return Math.min(restTime(this, position, velocity) * 1e3, crossing);
|
|
252
183
|
}
|
|
253
184
|
};
|
|
185
|
+
function solveEntry(f, threshold, peak) {
|
|
186
|
+
let low = 0;
|
|
187
|
+
let high = Math.max(peak, 1e-6);
|
|
188
|
+
while (f(high) > threshold) {
|
|
189
|
+
low = high;
|
|
190
|
+
high *= 2;
|
|
191
|
+
}
|
|
192
|
+
for (let i = 0; i < 60; i++) {
|
|
193
|
+
const mid = (low + high) / 2;
|
|
194
|
+
if (f(mid) > threshold) low = mid;
|
|
195
|
+
else high = mid;
|
|
196
|
+
}
|
|
197
|
+
return high;
|
|
198
|
+
}
|
|
199
|
+
function restTime(config, position, velocity) {
|
|
200
|
+
const { naturalFrequency, dampingRatio, restingMagnitude, arrival } = config;
|
|
201
|
+
if (dampingRatio < 1) {
|
|
202
|
+
const sigma = dampingRatio * naturalFrequency;
|
|
203
|
+
const wd = naturalFrequency * Math.sqrt(1 - dampingRatio ** 2);
|
|
204
|
+
const R = Math.hypot(position, (velocity + sigma * position) / wd);
|
|
205
|
+
const drop = Math.log(R * Math.sqrt(2 + 2 * dampingRatio) / restingMagnitude);
|
|
206
|
+
const plain = sigma > 0 ? drop / sigma : Infinity;
|
|
207
|
+
const strength = Math.abs(arrival);
|
|
208
|
+
if (strength === 0 || strength === 1) return plain;
|
|
209
|
+
const first = firstCrossing(config, position, velocity);
|
|
210
|
+
if (plain <= first) return plain;
|
|
211
|
+
const loss = -Math.log(strength) * wd / Math.PI;
|
|
212
|
+
return (drop + loss * first) / (sigma + loss);
|
|
213
|
+
}
|
|
214
|
+
if (dampingRatio === 1) {
|
|
215
|
+
const a0 = Math.abs(position) + Math.abs(velocity) / naturalFrequency;
|
|
216
|
+
const beta = 2 * Math.abs(velocity + naturalFrequency * position);
|
|
217
|
+
return solveEntry((t) => Math.exp(-naturalFrequency * t) * (a0 + beta * t), restingMagnitude, beta > 0 ? Math.max(0, 1 / naturalFrequency - a0 / beta) : 0);
|
|
218
|
+
}
|
|
219
|
+
const wd = naturalFrequency * Math.sqrt(dampingRatio ** 2 - 1);
|
|
220
|
+
const slow = dampingRatio * naturalFrequency - wd;
|
|
221
|
+
const fast = dampingRatio * naturalFrequency + wd;
|
|
222
|
+
const slowAmplitude = Math.abs(velocity + fast * position) / (2 * wd) * (1 + slow / naturalFrequency);
|
|
223
|
+
const fastAmplitude = Math.abs(velocity + slow * position) / (2 * wd) * (1 + fast / naturalFrequency);
|
|
224
|
+
return solveEntry((t) => slowAmplitude * Math.exp(-slow * t) + fastAmplitude * Math.exp(-fast * t), restingMagnitude, 0);
|
|
225
|
+
}
|
|
226
|
+
function restUnits(dampingRatio, ratio) {
|
|
227
|
+
if (dampingRatio < 1) return Math.log(ratio * Math.sqrt(2 / (1 - dampingRatio))) / dampingRatio;
|
|
228
|
+
if (dampingRatio === 1) return solveEntry((u) => Math.exp(-u) * (1 + 2 * u), 1 / ratio, .5);
|
|
229
|
+
const wd = Math.sqrt(dampingRatio ** 2 - 1);
|
|
230
|
+
const slow = dampingRatio - wd;
|
|
231
|
+
const fast = dampingRatio + wd;
|
|
232
|
+
const slowAmplitude = fast / (2 * wd) * (1 + slow);
|
|
233
|
+
const fastAmplitude = slow / (2 * wd) * (1 + fast);
|
|
234
|
+
return solveEntry((u) => slowAmplitude * Math.exp(-slow * u) + fastAmplitude * Math.exp(-fast * u), 1 / ratio, 0);
|
|
235
|
+
}
|
|
236
|
+
function firstCrossing(config, position, velocity) {
|
|
237
|
+
const { naturalFrequency, dampingRatio } = config;
|
|
238
|
+
if (dampingRatio < 1) {
|
|
239
|
+
const wd = naturalFrequency * Math.sqrt(1 - dampingRatio ** 2);
|
|
240
|
+
const c2 = (velocity + dampingRatio * naturalFrequency * position) / wd;
|
|
241
|
+
let theta = Math.atan2(-position, c2) % Math.PI;
|
|
242
|
+
if (theta <= 0) theta += Math.PI;
|
|
243
|
+
return theta / wd;
|
|
244
|
+
}
|
|
245
|
+
if (dampingRatio === 1) {
|
|
246
|
+
const t = -position / (velocity + naturalFrequency * position);
|
|
247
|
+
return t > 0 ? t : Infinity;
|
|
248
|
+
}
|
|
249
|
+
const wd = naturalFrequency * Math.sqrt(dampingRatio ** 2 - 1);
|
|
250
|
+
const theta = Math.atanh(-position * wd / (velocity + dampingRatio * naturalFrequency * position));
|
|
251
|
+
return theta > 0 ? theta / wd : Infinity;
|
|
252
|
+
}
|
|
254
253
|
function defineSpring(input) {
|
|
255
254
|
return new SpringDefinition(input);
|
|
256
255
|
}
|
|
@@ -294,139 +293,8 @@ var Emitter = class {
|
|
|
294
293
|
}
|
|
295
294
|
};
|
|
296
295
|
//#endregion
|
|
297
|
-
//#region src/state.ts
|
|
298
|
-
/**
|
|
299
|
-
* The mutable position/velocity pair a motion and its solvers share, in
|
|
300
|
-
* displacement space: position 0 means at the target.
|
|
301
|
-
*/
|
|
302
|
-
var State = class {
|
|
303
|
-
#config;
|
|
304
|
-
#position;
|
|
305
|
-
#velocity;
|
|
306
|
-
constructor(config, position, velocity) {
|
|
307
|
-
this.#config = config;
|
|
308
|
-
this.#position = position;
|
|
309
|
-
this.#velocity = velocity;
|
|
310
|
-
}
|
|
311
|
-
configure(config) {
|
|
312
|
-
this.#config = config;
|
|
313
|
-
}
|
|
314
|
-
get position() {
|
|
315
|
-
return this.#position;
|
|
316
|
-
}
|
|
317
|
-
set position(value) {
|
|
318
|
-
this.#position = value;
|
|
319
|
-
}
|
|
320
|
-
get velocity() {
|
|
321
|
-
return this.#velocity;
|
|
322
|
-
}
|
|
323
|
-
set velocity(value) {
|
|
324
|
-
this.#velocity = value;
|
|
325
|
-
}
|
|
326
|
-
/**
|
|
327
|
-
* The rest test: displacement, plus the displacement the current
|
|
328
|
-
* velocity could still convert into (|x| + |v| / wn), is inside the
|
|
329
|
-
* config's resting threshold.
|
|
330
|
-
*/
|
|
331
|
-
get isResting() {
|
|
332
|
-
return Math.abs(this.#position) + Math.abs(this.#velocity) / this.#config.naturalFrequency <= this.#config.restingMagnitude;
|
|
333
|
-
}
|
|
334
|
-
};
|
|
335
|
-
//#endregion
|
|
336
|
-
//#region src/solver.ts
|
|
337
|
-
/** dampingRatio < 1: decaying oscillation, x(t) = exp(-sigma*t) * (c1*cos(wd*t) + c2*sin(wd*t)). */
|
|
338
|
-
var UnderdampedSolver = class {
|
|
339
|
-
#state;
|
|
340
|
-
#dampedFrequency = 0;
|
|
341
|
-
#decayRate = 0;
|
|
342
|
-
#t = 0;
|
|
343
|
-
#c1 = 0;
|
|
344
|
-
#c2 = 0;
|
|
345
|
-
constructor(state) {
|
|
346
|
-
this.#state = state;
|
|
347
|
-
}
|
|
348
|
-
configure(config) {
|
|
349
|
-
if (config) {
|
|
350
|
-
this.#decayRate = config.dampingRatio * config.naturalFrequency;
|
|
351
|
-
this.#dampedFrequency = config.naturalFrequency * Math.sqrt(1 - config.dampingRatio ** 2);
|
|
352
|
-
}
|
|
353
|
-
this.#t = 0;
|
|
354
|
-
this.#c1 = this.#state.position;
|
|
355
|
-
this.#c2 = (this.#state.velocity + this.#decayRate * this.#state.position) / this.#dampedFrequency;
|
|
356
|
-
}
|
|
357
|
-
tick(dt) {
|
|
358
|
-
this.#t += dt;
|
|
359
|
-
const sin = Math.sin(this.#dampedFrequency * this.#t);
|
|
360
|
-
const cos = Math.cos(this.#dampedFrequency * this.#t);
|
|
361
|
-
const decay = Math.exp(-this.#decayRate * this.#t);
|
|
362
|
-
const decayVelocity = -this.#decayRate * decay;
|
|
363
|
-
const oscillation = this.#c1 * cos + this.#c2 * sin;
|
|
364
|
-
const oscillationVelocity = -this.#c1 * this.#dampedFrequency * sin + this.#c2 * this.#dampedFrequency * cos;
|
|
365
|
-
this.#state.position = decay * oscillation;
|
|
366
|
-
this.#state.velocity = decay * oscillationVelocity + decayVelocity * oscillation;
|
|
367
|
-
}
|
|
368
|
-
};
|
|
369
|
-
/** dampingRatio = 1: fastest non-oscillating decay, x(t) = (c1 + c2*t) * exp(-wn*t). */
|
|
370
|
-
var CriticallyDampedSolver = class {
|
|
371
|
-
#state;
|
|
372
|
-
#naturalFrequency = 0;
|
|
373
|
-
#t = 0;
|
|
374
|
-
#c1 = 0;
|
|
375
|
-
#c2 = 0;
|
|
376
|
-
constructor(state) {
|
|
377
|
-
this.#state = state;
|
|
378
|
-
}
|
|
379
|
-
configure(config) {
|
|
380
|
-
if (config) this.#naturalFrequency = config.naturalFrequency;
|
|
381
|
-
this.#t = 0;
|
|
382
|
-
this.#c1 = this.#state.position;
|
|
383
|
-
this.#c2 = this.#state.velocity + this.#naturalFrequency * this.#state.position;
|
|
384
|
-
}
|
|
385
|
-
tick(dt) {
|
|
386
|
-
this.#t += dt;
|
|
387
|
-
const decay = Math.exp(-this.#naturalFrequency * this.#t);
|
|
388
|
-
const decayVelocity = -this.#naturalFrequency * decay;
|
|
389
|
-
const scale = this.#c1 + this.#c2 * this.#t;
|
|
390
|
-
const scaleVelocity = this.#c2;
|
|
391
|
-
this.#state.position = scale * decay;
|
|
392
|
-
this.#state.velocity = scaleVelocity * decay + decayVelocity * scale;
|
|
393
|
-
}
|
|
394
|
-
};
|
|
395
|
-
/** dampingRatio > 1: slow non-oscillating decay, x(t) = exp(-sigma*t) * (c1*sinh(wd*t) + c2*cosh(wd*t)) / wd. */
|
|
396
|
-
var OverdampedSolver = class {
|
|
397
|
-
#state;
|
|
398
|
-
#dampedFrequency = 0;
|
|
399
|
-
#decayRate = 0;
|
|
400
|
-
#t = 0;
|
|
401
|
-
#c1 = 0;
|
|
402
|
-
#c2 = 0;
|
|
403
|
-
constructor(state) {
|
|
404
|
-
this.#state = state;
|
|
405
|
-
}
|
|
406
|
-
configure(config) {
|
|
407
|
-
if (config) {
|
|
408
|
-
this.#decayRate = config.dampingRatio * config.naturalFrequency;
|
|
409
|
-
this.#dampedFrequency = config.naturalFrequency * Math.sqrt(config.dampingRatio ** 2 - 1);
|
|
410
|
-
}
|
|
411
|
-
this.#t = 0;
|
|
412
|
-
this.#c1 = this.#state.velocity + this.#decayRate * this.#state.position;
|
|
413
|
-
this.#c2 = this.#state.position * this.#dampedFrequency;
|
|
414
|
-
}
|
|
415
|
-
tick(dt) {
|
|
416
|
-
this.#t += dt;
|
|
417
|
-
const decay = Math.exp(-this.#decayRate * this.#t);
|
|
418
|
-
const decayVelocity = -this.#decayRate * decay;
|
|
419
|
-
const clamped = Math.min(this.#dampedFrequency * this.#t, 300);
|
|
420
|
-
const sinh = Math.sinh(clamped);
|
|
421
|
-
const cosh = Math.cosh(clamped);
|
|
422
|
-
const scale = this.#c1 * sinh + this.#c2 * cosh;
|
|
423
|
-
const scaleVelocity = this.#c1 * this.#dampedFrequency * cosh + this.#c2 * this.#dampedFrequency * sinh;
|
|
424
|
-
this.#state.position = scale * decay / this.#dampedFrequency;
|
|
425
|
-
this.#state.velocity = (scale * decayVelocity + scaleVelocity * decay) / this.#dampedFrequency;
|
|
426
|
-
}
|
|
427
|
-
};
|
|
428
|
-
//#endregion
|
|
429
296
|
//#region src/motion.ts
|
|
297
|
+
let nextMotionId = 0;
|
|
430
298
|
/**
|
|
431
299
|
* One spring simulation in displacement space: position is measured from
|
|
432
300
|
* the target (0 means settled), so retargeting is a rebase performed by
|
|
@@ -434,8 +302,41 @@ var OverdampedSolver = class {
|
|
|
434
302
|
* damping regime and emits update/start/stop/dispose events.
|
|
435
303
|
*/
|
|
436
304
|
var Motion = class {
|
|
305
|
+
/** Monotone creation id: breaks ties wherever motions need a deterministic order. */
|
|
306
|
+
_id = nextMotionId++;
|
|
437
307
|
/** Tick-pass marker written by `MotionSet` so a motion re-added mid-pass isn't ticked twice. */
|
|
438
308
|
_pass = 0;
|
|
309
|
+
/**
|
|
310
|
+
* Frame marker written by `MotionSet`: sub-steps share one frame, and
|
|
311
|
+
* the first advance in it queues the motion for the settle sweep.
|
|
312
|
+
*/
|
|
313
|
+
_frame = 0;
|
|
314
|
+
/**
|
|
315
|
+
* Dependency rank written by `FollowGraph`: motions the follow graph
|
|
316
|
+
* touches count up from 0, leaders before followers; -1 marks a motion
|
|
317
|
+
* outside the graph.
|
|
318
|
+
*/
|
|
319
|
+
_rank = -1;
|
|
320
|
+
/**
|
|
321
|
+
* The edge this motion follows through, or null. Written by
|
|
322
|
+
* `FollowGraph` on edge add/remove so the tick's hot loop reads a
|
|
323
|
+
* field instead of a map.
|
|
324
|
+
*/
|
|
325
|
+
_edge = null;
|
|
326
|
+
/**
|
|
327
|
+
* A first-order-hold ramp slope in value units per second, armed by a
|
|
328
|
+
* ramped `recouple` immediately before the `_advance` that consumes
|
|
329
|
+
* it (read-and-clear; no user code runs between the two). 0 means the
|
|
330
|
+
* target holds still across the step — every path but a ramped
|
|
331
|
+
* recouple.
|
|
332
|
+
*/
|
|
333
|
+
_ramp = 0;
|
|
334
|
+
/**
|
|
335
|
+
* The edges reading this motion, or null when none. Written by
|
|
336
|
+
* `FollowGraph` with the rank cache, so it can lag a mid-pass edge
|
|
337
|
+
* change until the next recompute; the tick tolerates stale entries.
|
|
338
|
+
*/
|
|
339
|
+
_followers = null;
|
|
439
340
|
respectsReducedMotion = true;
|
|
440
341
|
#config;
|
|
441
342
|
#state;
|
|
@@ -445,7 +346,6 @@ var Motion = class {
|
|
|
445
346
|
#currentSolver = null;
|
|
446
347
|
#needsUpdate = false;
|
|
447
348
|
#needsReset = false;
|
|
448
|
-
#timeRemaining = 0;
|
|
449
349
|
#running;
|
|
450
350
|
#emitter;
|
|
451
351
|
constructor(config, position, velocity) {
|
|
@@ -454,7 +354,6 @@ var Motion = class {
|
|
|
454
354
|
this.#emitter = new Emitter();
|
|
455
355
|
this.#running = !this.#state.isResting;
|
|
456
356
|
this.#updateSolver();
|
|
457
|
-
this.#timeRemaining = this.#config.computeTimeRemaining(this.#state);
|
|
458
357
|
}
|
|
459
358
|
get position() {
|
|
460
359
|
return this.#state.position;
|
|
@@ -473,7 +372,7 @@ var Motion = class {
|
|
|
473
372
|
this.#syncStart();
|
|
474
373
|
}
|
|
475
374
|
get timeRemaining() {
|
|
476
|
-
return this.#
|
|
375
|
+
return this.#config.computeTimeRemaining(this.#state);
|
|
477
376
|
}
|
|
478
377
|
get isResting() {
|
|
479
378
|
return this.#state.isResting;
|
|
@@ -485,8 +384,50 @@ var Motion = class {
|
|
|
485
384
|
this.#syncStart();
|
|
486
385
|
}
|
|
487
386
|
tick(dt, emit = true) {
|
|
387
|
+
this._advance(dt);
|
|
388
|
+
if (emit) this._settleFrame();
|
|
389
|
+
else this.#reconcileRunning();
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Advances the simulation and emits nothing. The tick pass advances
|
|
393
|
+
* every motion through this, then delivers the frame's events with
|
|
394
|
+
* `_settleFrame`; the synchronous `tick` wraps the two.
|
|
395
|
+
*
|
|
396
|
+
* A pending `_ramp` integrates the step against a target moving at
|
|
397
|
+
* that slope instead of holding still: one affine transform around
|
|
398
|
+
* the unchanged solver. With the target at `p(t) = p0 + g*t`, the
|
|
399
|
+
* displacement u = x - p(t) obeys the homogeneous equation shifted by
|
|
400
|
+
* the constant u_ss = -(2*zeta/wn)*g = -(damping/tension)*g, so the
|
|
401
|
+
* solver ticks w = u - u_ss exactly and the results shift back.
|
|
402
|
+
* Measuring u from the moving target makes the ramp endpoint and the
|
|
403
|
+
* new target coincide — the armer assigns the target, no rebase
|
|
404
|
+
* arithmetic exists. The ramp is an argument of the step, never
|
|
405
|
+
* persistent solver state: g = 0 takes the plain step below, so a
|
|
406
|
+
* motion that never ramps steps bit-exactly like an isolated spring.
|
|
407
|
+
*/
|
|
408
|
+
_advance(dt) {
|
|
488
409
|
invariant(this.#currentSolver, "Cannot tick a disposed motion");
|
|
489
|
-
const
|
|
410
|
+
const g = this._ramp;
|
|
411
|
+
if (g !== 0) {
|
|
412
|
+
this._ramp = 0;
|
|
413
|
+
const state = this.#state;
|
|
414
|
+
const shift = this.#config.damping / this.#config.tension * g;
|
|
415
|
+
state.position += shift;
|
|
416
|
+
state.velocity -= g;
|
|
417
|
+
if (this.#needsUpdate) {
|
|
418
|
+
this.#updateSolver();
|
|
419
|
+
this.#needsUpdate = false;
|
|
420
|
+
} else this.#currentSolver.configure();
|
|
421
|
+
this.#currentSolver.tick(dt);
|
|
422
|
+
state.position -= shift;
|
|
423
|
+
state.velocity += g;
|
|
424
|
+
this.#needsReset = true;
|
|
425
|
+
if (state.isResting) {
|
|
426
|
+
state.position = 0;
|
|
427
|
+
state.velocity = 0;
|
|
428
|
+
}
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
490
431
|
if (this.#needsUpdate) {
|
|
491
432
|
this.#updateSolver();
|
|
492
433
|
this.#needsUpdate = false;
|
|
@@ -495,19 +436,26 @@ var Motion = class {
|
|
|
495
436
|
this.#currentSolver.configure();
|
|
496
437
|
this.#needsReset = false;
|
|
497
438
|
}
|
|
498
|
-
if (needsTimeRemaining) this.#timeRemaining = this.#config.computeTimeRemaining(this.#state);
|
|
499
439
|
this.#currentSolver.tick(dt);
|
|
500
|
-
this.#timeRemaining = Math.max(0, this.#timeRemaining - dt * 1e3);
|
|
501
440
|
if (this.#state.isResting) {
|
|
502
441
|
this.#state.position = 0;
|
|
503
442
|
this.#state.velocity = 0;
|
|
504
443
|
this.#needsReset = true;
|
|
505
444
|
}
|
|
506
|
-
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Emits the frame's `update`, then reconciles `stop`/`start`. The tick
|
|
448
|
+
* pass calls this in dependency order after every motion has advanced,
|
|
449
|
+
* so update callbacks read frame-final values everywhere.
|
|
450
|
+
*/
|
|
451
|
+
_settleFrame() {
|
|
452
|
+
this.#emitter.emit("update");
|
|
453
|
+
this.#reconcileRunning();
|
|
454
|
+
}
|
|
455
|
+
#reconcileRunning() {
|
|
507
456
|
if (this.#state.isResting) {
|
|
508
457
|
if (this.#running) {
|
|
509
458
|
this.#running = false;
|
|
510
|
-
this.#timeRemaining = 0;
|
|
511
459
|
this.#emitter.emit("stop");
|
|
512
460
|
}
|
|
513
461
|
} else if (!this.#running) {
|
|
@@ -520,6 +468,23 @@ var Motion = class {
|
|
|
520
468
|
this.velocity = 0;
|
|
521
469
|
this.tick(0);
|
|
522
470
|
}
|
|
471
|
+
/**
|
|
472
|
+
* The sub-step controller's shock signal, read by a follower's `plan`
|
|
473
|
+
* with this motion as its leader: deviation from the quasi-steady
|
|
474
|
+
* tracking manifold x = -(damping/tension) * v, where damping/tension
|
|
475
|
+
* is 2*zeta/wn. Near zero while chasing any smoothly moving target at
|
|
476
|
+
* any speed; the full displacement after a target teleport;
|
|
477
|
+
* (damping/tension) * |v| after a fling.
|
|
478
|
+
*/
|
|
479
|
+
_manifoldDeviation() {
|
|
480
|
+
const config = this.#config;
|
|
481
|
+
return Math.abs(this.#state.position + config.damping / config.tension * this.#state.velocity);
|
|
482
|
+
}
|
|
483
|
+
/** Acceleration from current state, for `plan`'s kinematic bound on a shock frame. */
|
|
484
|
+
_acceleration() {
|
|
485
|
+
const config = this.#config;
|
|
486
|
+
return -(config.tension * this.#state.position + config.damping * this.#state.velocity) / config.mass;
|
|
487
|
+
}
|
|
523
488
|
onUpdate(callback) {
|
|
524
489
|
return this.#emitter.on("update", callback);
|
|
525
490
|
}
|
|
@@ -785,6 +750,19 @@ function isSpringSource(value) {
|
|
|
785
750
|
return typeof value === "object" && value !== null && SpringSourceSymbol in value;
|
|
786
751
|
}
|
|
787
752
|
const RECIPES = /* @__PURE__ */ new WeakMap();
|
|
753
|
+
/** The flattened recipe behind `source`, when it is a derived source. */
|
|
754
|
+
function recipeOf(source) {
|
|
755
|
+
return RECIPES.get(source);
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Registers a derived source's recipe. Every source built on other
|
|
759
|
+
* sources must register at creation: `mapSpring` composition and
|
|
760
|
+
* `resolveLeaderMotions` both reach a derivation's roots through the
|
|
761
|
+
* registry rather than through the wrapper object.
|
|
762
|
+
*/
|
|
763
|
+
function registerRecipe(source, recipe) {
|
|
764
|
+
RECIPES.set(source, recipe);
|
|
765
|
+
}
|
|
788
766
|
function applyFns(fns, value) {
|
|
789
767
|
let result = value;
|
|
790
768
|
for (const fn of fns) result = fn(result);
|
|
@@ -879,6 +857,391 @@ function mapSpring(source, map) {
|
|
|
879
857
|
return mapped;
|
|
880
858
|
}
|
|
881
859
|
//#endregion
|
|
860
|
+
//#region src/follow-graph.ts
|
|
861
|
+
const MOTION_BACKING = /* @__PURE__ */ new WeakMap();
|
|
862
|
+
/**
|
|
863
|
+
* Records what advances `source`: its own `Motion` for a scalar spring,
|
|
864
|
+
* or the aggregated sources whose own registrations lead to motions (a
|
|
865
|
+
* composite registers its channel springs). Called from the
|
|
866
|
+
* `SpringSourceSymbol` brand getters on every api read — lazily, so
|
|
867
|
+
* springs never used as sources stay out of the weak registry, and
|
|
868
|
+
* idempotently, so repeated reads just overwrite the entry.
|
|
869
|
+
*/
|
|
870
|
+
function registerBacking(source, backing) {
|
|
871
|
+
MOTION_BACKING.set(source, backing);
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Resolves the motions behind `source`, deduplicated: every spring
|
|
875
|
+
* reached through any chain of derivations — `mapSpring` pipelines,
|
|
876
|
+
* `velocityOf`/`accelerationOf` wrappers, composite channels. A foreign
|
|
877
|
+
* source resolves to nothing: it imposes no tick-ordering constraint and
|
|
878
|
+
* couples through its emitter alone.
|
|
879
|
+
*/
|
|
880
|
+
function resolveLeaderMotions(source) {
|
|
881
|
+
const leaders = /* @__PURE__ */ new Set();
|
|
882
|
+
collectInto(source, leaders);
|
|
883
|
+
return [...leaders];
|
|
884
|
+
}
|
|
885
|
+
function collectInto(source, leaders) {
|
|
886
|
+
const recipe = recipeOf(source);
|
|
887
|
+
if (recipe) {
|
|
888
|
+
for (const root of recipe.sources) collectInto(root, leaders);
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
let backing = MOTION_BACKING.get(source);
|
|
892
|
+
if (backing === void 0 && isSpringSource(source)) {
|
|
893
|
+
const api = source[SpringSourceSymbol];
|
|
894
|
+
backing = MOTION_BACKING.get(source);
|
|
895
|
+
if (backing === void 0 && typeof api === "object" && api !== null && api !== source) {
|
|
896
|
+
collectInto(api, leaders);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
if (backing === void 0) return;
|
|
901
|
+
if (backing instanceof Motion) leaders.add(backing);
|
|
902
|
+
else for (const part of backing) collectInto(part, leaders);
|
|
903
|
+
}
|
|
904
|
+
const NO_LEADERS = [];
|
|
905
|
+
/**
|
|
906
|
+
* The follow graph as a walkable structure: one edge per following
|
|
907
|
+
* motion, and a cached dependency rank over every motion the edges
|
|
908
|
+
* touch. `MotionSet` owns one; springs register and unregister edges as
|
|
909
|
+
* follows are wired and torn down. What the tick reads per motion —
|
|
910
|
+
* `_edge`, `_followers`, `_rank` — lives on the motions themselves;
|
|
911
|
+
* this class is their writer.
|
|
912
|
+
*/
|
|
913
|
+
var FollowGraph = class {
|
|
914
|
+
#edges = /* @__PURE__ */ new Map();
|
|
915
|
+
/** Motions holding a rank from the last recompute, so stale ranks reset to -1. */
|
|
916
|
+
#ranked = [];
|
|
917
|
+
/** Motions holding a follower list from the last recompute, so stale lists reset to null. */
|
|
918
|
+
#withFollowers = [];
|
|
919
|
+
/** Edge list mirroring the map, rebuilt with the ranks, so the per-frame plan pass iterates an array. */
|
|
920
|
+
#edgeList = [];
|
|
921
|
+
#dirty = false;
|
|
922
|
+
/** Whether no edges are registered, letting the tick skip ordering work entirely. */
|
|
923
|
+
get isEmpty() {
|
|
924
|
+
return this.#edges.size === 0;
|
|
925
|
+
}
|
|
926
|
+
/** Every registered edge, valid after `ensureOrder`; the plan pass walks this once per frame. */
|
|
927
|
+
get edges() {
|
|
928
|
+
return this.#edgeList;
|
|
929
|
+
}
|
|
930
|
+
/**
|
|
931
|
+
* Registers `edge`, replacing any edge with the same follower: a
|
|
932
|
+
* spring follows at most one source at a time.
|
|
933
|
+
*/
|
|
934
|
+
addEdge(edge) {
|
|
935
|
+
this.#edges.set(edge.follower, edge);
|
|
936
|
+
edge.follower._edge = edge;
|
|
937
|
+
this.#dirty = true;
|
|
938
|
+
}
|
|
939
|
+
/** Drops the edge whose follower is `motion`, if one is registered. */
|
|
940
|
+
removeEdge(follower) {
|
|
941
|
+
if (this.#edges.delete(follower)) {
|
|
942
|
+
follower._edge = null;
|
|
943
|
+
this.#dirty = true;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Recomputes ranks if an edge was added or removed since the last
|
|
948
|
+
* call; clean calls return immediately. Afterward, motions the graph
|
|
949
|
+
* touches hold ranks counting up from 0 with every leader ranked
|
|
950
|
+
* before its followers, members of a cycle rank in ascending creation
|
|
951
|
+
* id, and motions outside the graph hold -1. Ranks are canonical: a
|
|
952
|
+
* function of graph structure and creation order, never of wiring
|
|
953
|
+
* order.
|
|
954
|
+
*/
|
|
955
|
+
ensureOrder() {
|
|
956
|
+
if (!this.#dirty) return;
|
|
957
|
+
this.#dirty = false;
|
|
958
|
+
this.#recompute();
|
|
959
|
+
}
|
|
960
|
+
#recompute() {
|
|
961
|
+
for (const motion of this.#ranked) motion._rank = -1;
|
|
962
|
+
for (const motion of this.#withFollowers) motion._followers = null;
|
|
963
|
+
const withFollowers = [];
|
|
964
|
+
for (const edge of this.#edges.values()) for (const leader of edge.leaders) {
|
|
965
|
+
if (leader._followers === null) {
|
|
966
|
+
leader._followers = [];
|
|
967
|
+
withFollowers.push(leader);
|
|
968
|
+
}
|
|
969
|
+
leader._followers.push(edge);
|
|
970
|
+
}
|
|
971
|
+
this.#withFollowers = withFollowers;
|
|
972
|
+
const nodes = /* @__PURE__ */ new Set();
|
|
973
|
+
for (const [follower, edge] of this.#edges) {
|
|
974
|
+
nodes.add(follower);
|
|
975
|
+
for (const leader of edge.leaders) nodes.add(leader);
|
|
976
|
+
}
|
|
977
|
+
const roots = [...nodes].sort((a, b) => a._id - b._id);
|
|
978
|
+
const indexOf = /* @__PURE__ */ new Map();
|
|
979
|
+
const lowOf = /* @__PURE__ */ new Map();
|
|
980
|
+
const onStack = /* @__PURE__ */ new Set();
|
|
981
|
+
const stack = [];
|
|
982
|
+
const ranked = [];
|
|
983
|
+
const sccOf = /* @__PURE__ */ new Map();
|
|
984
|
+
let index = 0;
|
|
985
|
+
let rank = 0;
|
|
986
|
+
let scc = 0;
|
|
987
|
+
for (const root of roots) {
|
|
988
|
+
if (indexOf.has(root)) continue;
|
|
989
|
+
const frames = [{
|
|
990
|
+
node: root,
|
|
991
|
+
leaders: this.#leadersOf(root),
|
|
992
|
+
next: 0
|
|
993
|
+
}];
|
|
994
|
+
indexOf.set(root, index);
|
|
995
|
+
lowOf.set(root, index);
|
|
996
|
+
index++;
|
|
997
|
+
stack.push(root);
|
|
998
|
+
onStack.add(root);
|
|
999
|
+
while (frames.length > 0) {
|
|
1000
|
+
const frame = frames.at(-1);
|
|
1001
|
+
if (frame.next < frame.leaders.length) {
|
|
1002
|
+
const leader = frame.leaders[frame.next++];
|
|
1003
|
+
const seen = indexOf.get(leader);
|
|
1004
|
+
if (seen === void 0) {
|
|
1005
|
+
indexOf.set(leader, index);
|
|
1006
|
+
lowOf.set(leader, index);
|
|
1007
|
+
index++;
|
|
1008
|
+
stack.push(leader);
|
|
1009
|
+
onStack.add(leader);
|
|
1010
|
+
frames.push({
|
|
1011
|
+
node: leader,
|
|
1012
|
+
leaders: this.#leadersOf(leader),
|
|
1013
|
+
next: 0
|
|
1014
|
+
});
|
|
1015
|
+
} else if (onStack.has(leader)) lowOf.set(frame.node, Math.min(lowOf.get(frame.node), seen));
|
|
1016
|
+
} else {
|
|
1017
|
+
frames.pop();
|
|
1018
|
+
const low = lowOf.get(frame.node);
|
|
1019
|
+
const parent = frames.at(-1);
|
|
1020
|
+
if (parent !== void 0) lowOf.set(parent.node, Math.min(lowOf.get(parent.node), low));
|
|
1021
|
+
if (low === indexOf.get(frame.node)) {
|
|
1022
|
+
const members = [];
|
|
1023
|
+
let member;
|
|
1024
|
+
do {
|
|
1025
|
+
member = stack.pop();
|
|
1026
|
+
onStack.delete(member);
|
|
1027
|
+
members.push(member);
|
|
1028
|
+
} while (member !== frame.node);
|
|
1029
|
+
members.sort((a, b) => a._id - b._id);
|
|
1030
|
+
for (const motion of members) {
|
|
1031
|
+
motion._rank = rank++;
|
|
1032
|
+
sccOf.set(motion, scc);
|
|
1033
|
+
ranked.push(motion);
|
|
1034
|
+
}
|
|
1035
|
+
scc++;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
this.#ranked = ranked;
|
|
1041
|
+
const edgeList = [...this.#edges.values()];
|
|
1042
|
+
for (const edge of edgeList) {
|
|
1043
|
+
const home = sccOf.get(edge.follower);
|
|
1044
|
+
edge._cyclic = edge.leaders.some((leader) => sccOf.get(leader) === home);
|
|
1045
|
+
}
|
|
1046
|
+
this.#edgeList = edgeList;
|
|
1047
|
+
}
|
|
1048
|
+
#leadersOf(motion) {
|
|
1049
|
+
return this.#edges.get(motion)?.leaders ?? NO_LEADERS;
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
1052
|
+
//#endregion
|
|
1053
|
+
//#region src/motion-set.ts
|
|
1054
|
+
function compareMotions(a, b) {
|
|
1055
|
+
return a._rank - b._rank || a._id - b._id;
|
|
1056
|
+
}
|
|
1057
|
+
const MAX_SUBSTEPS = 8;
|
|
1058
|
+
/**
|
|
1059
|
+
* A binary min-heap over (rank, creation id): the ordered pass worklist.
|
|
1060
|
+
* Duplicate pushes are expected — the tick's pass marker deduplicates at
|
|
1061
|
+
* pop.
|
|
1062
|
+
*/
|
|
1063
|
+
var MotionHeap = class {
|
|
1064
|
+
#items = [];
|
|
1065
|
+
clear() {
|
|
1066
|
+
this.#items.length = 0;
|
|
1067
|
+
}
|
|
1068
|
+
push(motion) {
|
|
1069
|
+
const items = this.#items;
|
|
1070
|
+
let hole = items.length;
|
|
1071
|
+
items.push(motion);
|
|
1072
|
+
while (hole > 0) {
|
|
1073
|
+
const parent = hole - 1 >> 1;
|
|
1074
|
+
if (compareMotions(items[parent], motion) <= 0) break;
|
|
1075
|
+
items[hole] = items[parent];
|
|
1076
|
+
hole = parent;
|
|
1077
|
+
}
|
|
1078
|
+
items[hole] = motion;
|
|
1079
|
+
}
|
|
1080
|
+
pop() {
|
|
1081
|
+
const items = this.#items;
|
|
1082
|
+
if (items.length === 0) return void 0;
|
|
1083
|
+
const top = items[0];
|
|
1084
|
+
const last = items.pop();
|
|
1085
|
+
if (items.length > 0) {
|
|
1086
|
+
let hole = 0;
|
|
1087
|
+
for (;;) {
|
|
1088
|
+
const left = 2 * hole + 1;
|
|
1089
|
+
if (left >= items.length) break;
|
|
1090
|
+
const right = left + 1;
|
|
1091
|
+
const child = right < items.length && compareMotions(items[right], items[left]) < 0 ? right : left;
|
|
1092
|
+
if (compareMotions(last, items[child]) <= 0) break;
|
|
1093
|
+
items[hole] = items[child];
|
|
1094
|
+
hole = child;
|
|
1095
|
+
}
|
|
1096
|
+
items[hole] = last;
|
|
1097
|
+
}
|
|
1098
|
+
return top;
|
|
1099
|
+
}
|
|
1100
|
+
};
|
|
1101
|
+
/**
|
|
1102
|
+
* The set of motions currently moving. Motions leave the set as they
|
|
1103
|
+
* rest and re-enter on any write that disturbs them; `onWake` tells the
|
|
1104
|
+
* ticker the set became non-empty so it can stop sleeping.
|
|
1105
|
+
*/
|
|
1106
|
+
var MotionSet = class {
|
|
1107
|
+
reduced = false;
|
|
1108
|
+
onWake = null;
|
|
1109
|
+
/**
|
|
1110
|
+
* The coupling error budget, in value units: the local per-frame error
|
|
1111
|
+
* the sub-step controller targets on each follow edge, floored by the
|
|
1112
|
+
* follower's resting magnitude. `SpringSystem` exposes it as the
|
|
1113
|
+
* `couplingTolerance` option and property; validation lives there.
|
|
1114
|
+
*/
|
|
1115
|
+
couplingTolerance = .1;
|
|
1116
|
+
flushes = new FlushQueue();
|
|
1117
|
+
/** Follow edges registered by following springs, with the dependency rank over their motions. */
|
|
1118
|
+
graph = new FollowGraph();
|
|
1119
|
+
#motions = /* @__PURE__ */ new Set();
|
|
1120
|
+
#heap = new MotionHeap();
|
|
1121
|
+
#advanced = [];
|
|
1122
|
+
/** The live pass worklist while an advance phase runs, so mid-phase wakes join it. */
|
|
1123
|
+
#activeHeap = null;
|
|
1124
|
+
/** Whether any tick is on the stack, settle sweep included — a reentrant advance must not share the pass structures. */
|
|
1125
|
+
#inTick = false;
|
|
1126
|
+
#debug;
|
|
1127
|
+
#lastSize = 0;
|
|
1128
|
+
#pass = 0;
|
|
1129
|
+
/** Ordered-frame counter: sub-steps share one id, deduplicating the settle list. */
|
|
1130
|
+
#frame = 0;
|
|
1131
|
+
constructor(debug = false) {
|
|
1132
|
+
this.#debug = debug;
|
|
1133
|
+
}
|
|
1134
|
+
get size() {
|
|
1135
|
+
return this.#motions.size;
|
|
1136
|
+
}
|
|
1137
|
+
add(motion) {
|
|
1138
|
+
const wasEmpty = this.#motions.size === 0;
|
|
1139
|
+
this.#motions.add(motion);
|
|
1140
|
+
this.#activeHeap?.push(motion);
|
|
1141
|
+
if (wasEmpty) this.onWake?.();
|
|
1142
|
+
}
|
|
1143
|
+
remove(motion) {
|
|
1144
|
+
this.#motions.delete(motion);
|
|
1145
|
+
}
|
|
1146
|
+
finishAll() {
|
|
1147
|
+
this.flushes.batch(() => {
|
|
1148
|
+
for (const motion of this.#motions) {
|
|
1149
|
+
if (!motion.respectsReducedMotion) continue;
|
|
1150
|
+
motion.finish();
|
|
1151
|
+
this.#motions.delete(motion);
|
|
1152
|
+
}
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
tick(dt) {
|
|
1156
|
+
const reentrant = this.#inTick;
|
|
1157
|
+
this.#inTick = true;
|
|
1158
|
+
this.flushes.enter();
|
|
1159
|
+
try {
|
|
1160
|
+
this.graph.ensureOrder();
|
|
1161
|
+
if (this.graph.isEmpty) this.#tickUnordered(dt, reentrant);
|
|
1162
|
+
else this.#tickOrdered(dt, reentrant);
|
|
1163
|
+
} finally {
|
|
1164
|
+
this.#inTick = reentrant;
|
|
1165
|
+
this.flushes.exit();
|
|
1166
|
+
}
|
|
1167
|
+
if (this.#debug && this.#motions.size !== this.#lastSize) {
|
|
1168
|
+
this.#lastSize = this.#motions.size;
|
|
1169
|
+
console.log(`coily: ${this.#lastSize} active motions`);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
/** The no-edge fast path: nothing constrains order, so insertion order serves for free. */
|
|
1173
|
+
#tickUnordered(dt, reentrant) {
|
|
1174
|
+
this.#pass++;
|
|
1175
|
+
const advanced = reentrant ? [] : this.#advanced;
|
|
1176
|
+
advanced.length = 0;
|
|
1177
|
+
for (const motion of this.#motions) {
|
|
1178
|
+
if (motion._pass === this.#pass) continue;
|
|
1179
|
+
motion._pass = this.#pass;
|
|
1180
|
+
motion._advance(dt);
|
|
1181
|
+
advanced.push(motion);
|
|
1182
|
+
if (motion.isResting) this.#motions.delete(motion);
|
|
1183
|
+
}
|
|
1184
|
+
this.#settle(advanced);
|
|
1185
|
+
}
|
|
1186
|
+
#tickOrdered(dt, reentrant) {
|
|
1187
|
+
const heap = reentrant ? new MotionHeap() : this.#heap;
|
|
1188
|
+
const advanced = reentrant ? [] : this.#advanced;
|
|
1189
|
+
const previous = this.#activeHeap;
|
|
1190
|
+
this.#activeHeap = heap;
|
|
1191
|
+
advanced.length = 0;
|
|
1192
|
+
const frame = ++this.#frame;
|
|
1193
|
+
let demand = 1;
|
|
1194
|
+
if (!this.reduced) {
|
|
1195
|
+
for (const edge of this.graph.edges) if (this.#shouldPlan(edge)) {
|
|
1196
|
+
const k = edge.plan(dt);
|
|
1197
|
+
if (k > demand) demand = k;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
const K = Math.min(demand, MAX_SUBSTEPS);
|
|
1201
|
+
const h = dt / K;
|
|
1202
|
+
try {
|
|
1203
|
+
for (let step = 0; step < K; step++) this.#advancePass(dt, h, step === 0, heap, advanced, frame);
|
|
1204
|
+
} finally {
|
|
1205
|
+
this.#activeHeap = previous;
|
|
1206
|
+
}
|
|
1207
|
+
this.#settle(advanced);
|
|
1208
|
+
}
|
|
1209
|
+
#advancePass(dt, h, first, heap, advanced, frame) {
|
|
1210
|
+
this.#pass++;
|
|
1211
|
+
heap.clear();
|
|
1212
|
+
for (const motion of this.#motions) {
|
|
1213
|
+
if (!first && motion._rank === -1) continue;
|
|
1214
|
+
heap.push(motion);
|
|
1215
|
+
}
|
|
1216
|
+
for (let motion = heap.pop(); motion !== void 0; motion = heap.pop()) {
|
|
1217
|
+
if (motion._pass === this.#pass) continue;
|
|
1218
|
+
const edge = motion._edge;
|
|
1219
|
+
if (edge !== null && this.#shouldRecouple(edge)) edge.recouple(h);
|
|
1220
|
+
if (!this.#motions.has(motion)) continue;
|
|
1221
|
+
motion._pass = this.#pass;
|
|
1222
|
+
motion._advance(motion._rank === -1 ? dt : h);
|
|
1223
|
+
if (motion._frame !== frame) {
|
|
1224
|
+
motion._frame = frame;
|
|
1225
|
+
advanced.push(motion);
|
|
1226
|
+
}
|
|
1227
|
+
if (motion.isResting) this.#motions.delete(motion);
|
|
1228
|
+
const followers = motion._followers;
|
|
1229
|
+
if (followers !== null) for (const out of followers) heap.push(out.follower);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
#settle(advanced) {
|
|
1233
|
+
for (const motion of advanced) motion._settleFrame();
|
|
1234
|
+
}
|
|
1235
|
+
#shouldRecouple(edge) {
|
|
1236
|
+
for (const leader of edge.leaders) if (leader._pass === this.#pass || this.#motions.has(leader)) return true;
|
|
1237
|
+
return false;
|
|
1238
|
+
}
|
|
1239
|
+
#shouldPlan(edge) {
|
|
1240
|
+
for (const leader of edge.leaders) if (this.#motions.has(leader)) return true;
|
|
1241
|
+
return false;
|
|
1242
|
+
}
|
|
1243
|
+
};
|
|
1244
|
+
//#endregion
|
|
882
1245
|
//#region src/spring.ts
|
|
883
1246
|
/**
|
|
884
1247
|
* One animated number, driven toward its target with damped spring
|
|
@@ -895,6 +1258,7 @@ function mapSpring(source, map) {
|
|
|
895
1258
|
var Spring = class {
|
|
896
1259
|
/** Brands the spring as a `KinematicSource` whose api is the spring itself. */
|
|
897
1260
|
get [SpringSourceSymbol]() {
|
|
1261
|
+
registerBacking(this, this.#motion);
|
|
898
1262
|
return this;
|
|
899
1263
|
}
|
|
900
1264
|
#target;
|
|
@@ -1040,6 +1404,10 @@ var Spring = class {
|
|
|
1040
1404
|
get precision() {
|
|
1041
1405
|
return this.#config.precision;
|
|
1042
1406
|
}
|
|
1407
|
+
/** The config's arrival multiplier. */
|
|
1408
|
+
get arrival() {
|
|
1409
|
+
return this.#config.arrival;
|
|
1410
|
+
}
|
|
1043
1411
|
set config(value) {
|
|
1044
1412
|
const next = value ?? SpringDefinition.default;
|
|
1045
1413
|
if (this.#config === next) return;
|
|
@@ -1048,9 +1416,12 @@ var Spring = class {
|
|
|
1048
1416
|
if (!this.#motion.isResting) this.#motions.add(this.#motion);
|
|
1049
1417
|
}
|
|
1050
1418
|
/**
|
|
1051
|
-
*
|
|
1052
|
-
*
|
|
1053
|
-
*
|
|
1419
|
+
* Milliseconds until the spring rests: 0 while resting, Infinity when
|
|
1420
|
+
* the motion never settles. The time is solved from the motion, not
|
|
1421
|
+
* estimated — the spring is resting at the first tick at or after it,
|
|
1422
|
+
* and a bouncy spring can rest up to one oscillation earlier when a
|
|
1423
|
+
* frame samples a low pulse. When the config's `arrival` is 0, it is
|
|
1424
|
+
* capped at the exact ms of the first target crossing.
|
|
1054
1425
|
*/
|
|
1055
1426
|
get timeRemaining() {
|
|
1056
1427
|
return this.#motion.timeRemaining;
|
|
@@ -1174,9 +1545,59 @@ var Spring = class {
|
|
|
1174
1545
|
const unsubDispose = api.onDispose(() => {
|
|
1175
1546
|
this.#unfollow();
|
|
1176
1547
|
});
|
|
1548
|
+
const leaders = resolveLeaderMotions(source);
|
|
1549
|
+
if (leaders.length > 0) {
|
|
1550
|
+
const edge = {
|
|
1551
|
+
follower: this.#motion,
|
|
1552
|
+
leaders,
|
|
1553
|
+
_cyclic: false,
|
|
1554
|
+
_prevValue: 0,
|
|
1555
|
+
_d1: 0,
|
|
1556
|
+
_d2: 0,
|
|
1557
|
+
recouple: (h) => {
|
|
1558
|
+
if (edge._cyclic || h === 0 || this.#config.arrival !== 1 || this.#motions.reduced && this.#purpose === "motion") {
|
|
1559
|
+
this.#setTarget(api.value);
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
const value = api.value;
|
|
1563
|
+
invariant(Number.isFinite(value), "Spring target must be a finite number");
|
|
1564
|
+
if (value === this.#target) return;
|
|
1565
|
+
this.#motions.add(this.#motion);
|
|
1566
|
+
this.#motion._ramp = (value - this.#target) / h;
|
|
1567
|
+
this.#target = value;
|
|
1568
|
+
},
|
|
1569
|
+
plan: (dt) => {
|
|
1570
|
+
const target = this.#target;
|
|
1571
|
+
const d = target - edge._prevValue;
|
|
1572
|
+
edge._d2 = edge._d1;
|
|
1573
|
+
edge._d1 = d;
|
|
1574
|
+
edge._prevValue = target;
|
|
1575
|
+
let dHat = Math.abs(d);
|
|
1576
|
+
let error = Math.abs(d - edge._d2) / 8;
|
|
1577
|
+
for (const leader of leaders) if (leader._manifoldDeviation() > 4 * Math.abs(d)) {
|
|
1578
|
+
const accel = Math.abs(leader._acceleration());
|
|
1579
|
+
const speed = Math.abs(leader.velocity);
|
|
1580
|
+
dHat = Math.max(dHat, speed * dt + .5 * accel * dt * dt);
|
|
1581
|
+
error = Math.max(error, accel * dt * dt / 8);
|
|
1582
|
+
}
|
|
1583
|
+
const tol = Math.max(this.#config.restingMagnitude, this.#motions.couplingTolerance);
|
|
1584
|
+
return edge._cyclic || this.#config.arrival !== 1 ? Math.ceil(dHat / (2 * tol)) : Math.ceil(Math.sqrt(error / tol));
|
|
1585
|
+
}
|
|
1586
|
+
};
|
|
1587
|
+
this.#motions.graph.addEdge(edge);
|
|
1588
|
+
this.#unsubLeader = () => {
|
|
1589
|
+
unsubUpdate();
|
|
1590
|
+
unsubDispose();
|
|
1591
|
+
this.#motions.graph.removeEdge(this.#motion);
|
|
1592
|
+
};
|
|
1593
|
+
this.#setTarget(api.value);
|
|
1594
|
+
edge._prevValue = this.#target;
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1177
1597
|
this.#unsubLeader = () => {
|
|
1178
1598
|
unsubUpdate();
|
|
1179
1599
|
unsubDispose();
|
|
1600
|
+
this.#motions.graph.removeEdge(this.#motion);
|
|
1180
1601
|
};
|
|
1181
1602
|
this.#setTarget(api.value);
|
|
1182
1603
|
}
|
|
@@ -1268,6 +1689,7 @@ const followChannel = (mine, theirs) => {
|
|
|
1268
1689
|
var CompositeSpring = class CompositeSpring {
|
|
1269
1690
|
/** Brands the composite as a `KinematicSource` whose api is the composite itself. */
|
|
1270
1691
|
get [SpringSourceSymbol]() {
|
|
1692
|
+
registerBacking(this, this.#tree.leaves);
|
|
1271
1693
|
return this;
|
|
1272
1694
|
}
|
|
1273
1695
|
#motions;
|
|
@@ -1676,6 +2098,7 @@ var SpringSystemImpl = class {
|
|
|
1676
2098
|
constructor(options) {
|
|
1677
2099
|
this.#motion = new MotionSet(options?.debug);
|
|
1678
2100
|
this.#ticker = new Ticker(this.#motion, options);
|
|
2101
|
+
if (options?.couplingTolerance !== void 0) this.couplingTolerance = options.couplingTolerance;
|
|
1679
2102
|
const mode = options?.reducedMotion ?? "user";
|
|
1680
2103
|
if (mode === "always") this.#motion.reduced = true;
|
|
1681
2104
|
else if (mode === "user" && typeof window !== "undefined" && typeof window.matchMedia === "function") {
|
|
@@ -1693,6 +2116,13 @@ var SpringSystemImpl = class {
|
|
|
1693
2116
|
get reducedMotion() {
|
|
1694
2117
|
return this.#motion.reduced;
|
|
1695
2118
|
}
|
|
2119
|
+
get couplingTolerance() {
|
|
2120
|
+
return this.#motion.couplingTolerance;
|
|
2121
|
+
}
|
|
2122
|
+
set couplingTolerance(value) {
|
|
2123
|
+
invariant(Number.isFinite(value) && value >= 0, "couplingTolerance must be a finite, non-negative number");
|
|
2124
|
+
this.#motion.couplingTolerance = value;
|
|
2125
|
+
}
|
|
1696
2126
|
createSpring(value, config, options) {
|
|
1697
2127
|
if (typeof value === "number") return new Spring(this.#motion, value, config, options?.purpose);
|
|
1698
2128
|
if (isSpringSource(value)) {
|
|
@@ -1755,4 +2185,4 @@ function createSpringSystem(options) {
|
|
|
1755
2185
|
return new SpringSystemImpl(options);
|
|
1756
2186
|
}
|
|
1757
2187
|
//#endregion
|
|
1758
|
-
export {
|
|
2188
|
+
export { registerRecipe as a, mapSpring as i, SpringSourceSymbol as n, SpringDefinition as o, isSpringSource as r, defineSpring as s, createSpringSystem as t };
|