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.
@@ -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
- let sigmaCoeff;
207
- if (dampingRatio < 1) sigmaCoeff = dampingRatio;
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
- * Estimates how long a spring with this config takes to come to rest
235
- * from `state`, in milliseconds.
160
+ * Computes when a spring with this config comes to rest from `state`,
161
+ * in ms.
236
162
  *
237
- * The estimate follows the motion's decay envelope with a 2x safety
238
- * margin, so actual rest usually lands earlier. Returns 0 when `state`
239
- * is already inside the resting threshold, and Infinity when the config
240
- * never settles (a `dampingRatio` of 0).
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
- const a0 = Math.abs(position) + Math.abs(velocity) / this.naturalFrequency;
245
- if (a0 <= this.restingMagnitude) return 0;
246
- let sigma;
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.#timeRemaining;
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 needsTimeRemaining = this.#needsUpdate || this.#needsReset;
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
- if (emit) this.#emitter.emit("update");
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
- * Estimated milliseconds until the spring rests: 0 while resting,
1052
- * Infinity when undamped. The estimate carries a safety margin, so
1053
- * actual rest usually lands earlier.
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 { SpringDefinition as a, mapSpring as i, SpringSourceSymbol as n, defineSpring as o, isSpringSource as r, invariant as s, createSpringSystem as t };
2188
+ export { registerRecipe as a, mapSpring as i, SpringSourceSymbol as n, SpringDefinition as o, isSpringSource as r, defineSpring as s, createSpringSystem as t };