@wave3d/core 0.2.2 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +47 -0
  2. package/dist/config/model.d.ts +138 -1
  3. package/dist/config/model.js +144 -1
  4. package/dist/config/model.js.map +1 -1
  5. package/dist/index.d.ts +3 -2
  6. package/dist/index.js +2 -2
  7. package/dist/presets.js +15 -0
  8. package/dist/presets.js.map +1 -1
  9. package/dist/renderer/WaveRenderer.d.ts +59 -0
  10. package/dist/renderer/WaveRenderer.js +422 -8
  11. package/dist/renderer/WaveRenderer.js.map +1 -1
  12. package/dist/renderer/interaction.d.ts +119 -0
  13. package/dist/renderer/interaction.js +474 -0
  14. package/dist/renderer/interaction.js.map +1 -0
  15. package/dist/renderer/palette.js +14 -0
  16. package/dist/renderer/palette.js.map +1 -1
  17. package/dist/renderer/shaders.js +313 -1
  18. package/dist/renderer/shaders.js.map +1 -1
  19. package/dist/shell/createWave.d.ts +9 -1
  20. package/dist/shell/createWave.js +7 -1
  21. package/dist/shell/createWave.js.map +1 -1
  22. package/dist/shell/poster.d.ts +12 -0
  23. package/dist/shell/poster.js +4 -3
  24. package/dist/shell/poster.js.map +1 -1
  25. package/dist/standalone/wave3d.standalone.js +1016 -223
  26. package/dist/standalone.d.ts +2 -2
  27. package/dist/standalone.js +2 -2
  28. package/dist/studio/StudioWaveRenderer.d.ts +15 -0
  29. package/dist/studio/StudioWaveRenderer.js +32 -1
  30. package/dist/studio/StudioWaveRenderer.js.map +1 -1
  31. package/dist/studio/index.d.ts +2 -2
  32. package/dist/studio/index.js +2 -2
  33. package/dist/studio/randomize.d.ts +7 -4
  34. package/dist/studio/randomize.js +52 -5
  35. package/dist/studio/randomize.js.map +1 -1
  36. package/package.json +1 -1
  37. package/skills/wave3d/SKILL.md +60 -6
@@ -0,0 +1,119 @@
1
+ import { SceneInteractionBinding, StudioConfig, WaveInteractionBinding } from "../config/model.js";
2
+ import * as THREE from "three";
3
+
4
+ //#region src/renderer/interaction.d.ts
5
+ type AnyBinding = WaveInteractionBinding | SceneInteractionBinding;
6
+ /** What a wave-scoped applier writes into: one wave's uniforms + its mesh transform. */
7
+ interface RippleSlot {
8
+ origin: THREE.Vector2;
9
+ age: number;
10
+ amp: number;
11
+ }
12
+ /** A per-frame snapshot of the pointer-field state. Fields are LIVE references into the controller's
13
+ * state — read them synchronously each frame; don't retain them. */
14
+ interface InteractionSample {
15
+ /** Smoothed pointer position, NDC (-1..1). */
16
+ ndc: THREE.Vector2;
17
+ /** Smoothed pointer presence 0..1 (→ uPointerActive). */
18
+ presence: number;
19
+ /** Click-ripple ring buffer (amp = shared 0..1 envelope; 0 = free slot). */
20
+ ripples: readonly RippleSlot[];
21
+ }
22
+ /** A wave's own smoothed pointer-field state — trails the shared cursor at the wave's own rate. */
23
+ interface PointerField {
24
+ /** Smoothed pointer position for this wave, NDC (-1..1). */
25
+ ndc: THREE.Vector2;
26
+ /** Spring velocity of `ndc` (NDC/s) — internal spring state, not read by the renderer. */
27
+ vel: THREE.Vector2;
28
+ /** Smoothed pointer presence 0..1 for this wave. */
29
+ presence: number;
30
+ }
31
+ /**
32
+ * Owns the one cursor's input + scroll + press/appear/custom and all smoothing. Constructed by the
33
+ * renderer when {@link interactionActive} first turns true, disposed when it turns false. All
34
+ * listeners are passive and container-scoped (the poster overlay passes events through).
35
+ */
36
+ declare class InteractionController {
37
+ private readonly container;
38
+ private readonly cfg;
39
+ /** Studio-only scroll preview: when non-null, overrides the computed scroll progress. */
40
+ scrollOverride: number | null;
41
+ private readonly ndc;
42
+ private readonly ndcTarget;
43
+ private readonly ndcPrev;
44
+ private readonly velNdc;
45
+ private presence;
46
+ private presenceTarget;
47
+ private press;
48
+ private pressTarget;
49
+ private pointerSpeed;
50
+ private scroll;
51
+ private scrollPrev;
52
+ private scrollVel;
53
+ private appearLatched;
54
+ private readonly customInputs;
55
+ private readonly ripples;
56
+ private readonly fields;
57
+ private readonly bindingState;
58
+ private readonly seenBindings;
59
+ private readonly out;
60
+ constructor(container: HTMLElement, cfg: () => StudioConfig | undefined);
61
+ /** Ignore coarse (touch) pointers unless the scene opts in with interaction.touch. */
62
+ private ignore;
63
+ private setNdcTarget;
64
+ private onPointerEnter;
65
+ private onPointerMove;
66
+ private onPointerLeave;
67
+ private onPointerCancel;
68
+ private onPointerDown;
69
+ private onPointerUp;
70
+ /** Spawn a normalized ripple (envelope 0..1) at the click NDC; per-wave amplitude scales it in the
71
+ * shader. Reuses a free slot or evicts the oldest. */
72
+ private spawnRipple;
73
+ /** Advance all smoothed state by `dt` seconds. Called from the render loop with the same delta. */
74
+ update(dt: number): void;
75
+ private updateBindings;
76
+ /** Advance one binding's smoothed source value by `dt` and mark it live in `seenBindings`. */
77
+ private advanceBinding;
78
+ /** The current smoothed 0..1 value of a binding's source (0 if the binding is unknown). */
79
+ bindingValue(b: AnyBinding): number;
80
+ /** The current raw (un-per-binding-smoothed) 0..1 value of a source signal. */
81
+ private rawSource;
82
+ /** Container progress through the viewport: 0 as it enters from below, 1 once scrolled past. */
83
+ private computeScroll;
84
+ /** The shared pointer-field state + ripples for the renderer (live references — read synchronously). */
85
+ sample(): InteractionSample;
86
+ /** This wave's smoothed pointer-field state (it trails the cursor at its own hover smoothing), or
87
+ * null if the wave hasn't been advanced by update() yet (treat as rest). */
88
+ fieldFor(waveIdx: number): PointerField | null;
89
+ /** Velocity-driven agitation drive 0..1 (how fast the cursor is moving, presence-gated). The
90
+ * renderer scales each wave's hover `agitate` by this, so the churn tracks the gesture instead
91
+ * of buzzing at a constant rate whenever the cursor is merely present. */
92
+ pointerFlux(): number;
93
+ /** Smoothed pointer velocity, NDC/s (direction + speed of the drag). The renderer feeds it to the
94
+ * drag-wake shader so the trailing trough forms behind the motion. Live reference — read per frame. */
95
+ pointerVelocity(): THREE.Vector2;
96
+ /** Feed a `custom:<name>` input (developer API; staged/forwarded by the shell). */
97
+ setInput(name: string, value: number): void;
98
+ /**
99
+ * Collapse to the settled resting state for the single frame drawn when the loop stops (paused /
100
+ * reduced-motion / offscreen): presence / velocity / press / pointerSpeed → 0, ripples cleared,
101
+ * scroll → its current raw value, pointer → centre, and `appear` → 1 (reduced-motion users must
102
+ * see the FINAL entered state). Custom inputs KEEP their last explicit values. Each binding snaps
103
+ * to its settled source so the one settled frame shows the final look.
104
+ */
105
+ settle(): void;
106
+ /**
107
+ * Snap scroll progress + the scroll-sourced bindings to the current override at once, leaving
108
+ * every other input (pointer / press / appear / velocity / custom) advancing live. Used by the
109
+ * studio scroll preview: the studio page never really scrolls, so dragging the preview slider is a
110
+ * manual scrub that must reflect the instant you move it — not on the next animation frame, which
111
+ * the browser fully suspends whenever the tab isn't foreground. Unlike settle() (which collapses
112
+ * ALL input to rest for a paused still frame), this touches only the scroll signal.
113
+ */
114
+ snapScroll(): void;
115
+ dispose(): void;
116
+ }
117
+ //#endregion
118
+ export { InteractionController, InteractionSample, PointerField };
119
+ //# sourceMappingURL=interaction.d.ts.map
@@ -0,0 +1,474 @@
1
+ import { clamp01 } from "../util/math.js";
2
+ import * as THREE from "three";
3
+ const RIPPLE_LIFETIME = 1.5;
4
+ const VELOCITY_TAU = .08;
5
+ const POINTER_SPEED_REF = 4;
6
+ const SCROLL_VELOCITY_REF = 2;
7
+ const SCROLL_VELOCITY_TAU = .15;
8
+ const DEFAULT_POINTER_TAU = .12;
9
+ const DEFAULT_BINDING_TAU = .25;
10
+ const POINTER_SPRING_ZETA = .7;
11
+ const MIN_POINTER_TAU = .02;
12
+ const SPRING_MAX_STEP = 1 / 120;
13
+ const SPRING_MAX_SUBSTEPS = 6;
14
+ /** Frame-rate-independent exponential smoothing factor for time constant `tau` (seconds). */
15
+ function alpha(tau, dt) {
16
+ return tau > 0 ? 1 - Math.exp(-dt / tau) : 1;
17
+ }
18
+ /**
19
+ * Advance a damped spring (`pos`/`vel`) toward `target` by `dt`, using semi-implicit (symplectic)
20
+ * Euler. `omega` is the natural angular frequency (≈ 1/response-time), `zeta` the damping ratio
21
+ * (<1 underdamped → overshoots and settles; 1 critical; >1 sluggish). Unlike a first-order lag this
22
+ * carries momentum, so motion has weight and settles instead of creeping to a dead stop. Substeps
23
+ * when `dt` spikes (e.g. the tab was backgrounded) so a stiff spring can't blow up; ~1 step at 60fps.
24
+ */
25
+ function springVec2(pos, vel, target, omega, zeta, dt) {
26
+ if (dt <= 0) return;
27
+ const steps = dt > SPRING_MAX_STEP ? Math.min(Math.ceil(dt / SPRING_MAX_STEP), SPRING_MAX_SUBSTEPS) : 1;
28
+ const h = dt / steps;
29
+ const k = omega * omega;
30
+ const c = 2 * zeta * omega;
31
+ for (let s = 0; s < steps; s++) {
32
+ vel.x += (k * (target.x - pos.x) - c * vel.x) * h;
33
+ vel.y += (k * (target.y - pos.y) - c * vel.y) * h;
34
+ pos.x += vel.x * h;
35
+ pos.y += vel.y * h;
36
+ }
37
+ }
38
+ const waveApplier = (base, apply) => ({
39
+ base,
40
+ apply
41
+ });
42
+ const sceneApplier = (base, apply) => ({
43
+ base,
44
+ apply
45
+ });
46
+ /**
47
+ * Per-wave binding targets → (how to read the authored base value, how to write the modulated one).
48
+ * Each base() mirrors the exact fallback refresh() uses, so a binding at rest (from omitted, source
49
+ * 0) writes the same value the renderer already had — no visible jump. This object is the runtime
50
+ * source of truth for {@link WaveInteractionTarget} (enforced by `satisfies`).
51
+ */
52
+ const WAVE_APPLIERS = {
53
+ displaceAmount: waveApplier((w) => w.displaceAmount, (v, a) => {
54
+ a.u.uDispAmount.value = v;
55
+ }),
56
+ detailAmount: waveApplier((w) => w.detailAmount ?? 0, (v, a) => {
57
+ a.u.uDetailAmount.value = v;
58
+ }),
59
+ twistPowerX: waveApplier((w) => w.twistPower.x, (v, a) => {
60
+ a.u.uTwPowX.value = v;
61
+ }),
62
+ twistPowerY: waveApplier((w) => w.twistPower.y, (v, a) => {
63
+ a.u.uTwPowY.value = v;
64
+ }),
65
+ twistPowerZ: waveApplier((w) => w.twistPower.z, (v, a) => {
66
+ a.u.uTwPowZ.value = v;
67
+ }),
68
+ twistFrequencyX: waveApplier((w) => w.twistFrequency.x, (v, a) => {
69
+ a.u.uTwFreqX.value = v;
70
+ }),
71
+ twistFrequencyY: waveApplier((w) => w.twistFrequency.y, (v, a) => {
72
+ a.u.uTwFreqY.value = v;
73
+ }),
74
+ twistFrequencyZ: waveApplier((w) => w.twistFrequency.z, (v, a) => {
75
+ a.u.uTwFreqZ.value = v;
76
+ }),
77
+ hueShift: waveApplier((w) => w.hueShift, (v, a) => {
78
+ a.u.uHueShift.value = v;
79
+ }),
80
+ gradientShift: waveApplier((w) => w.gradientShift ?? 0, (v, a) => {
81
+ a.u.uGradShift.value = v;
82
+ }),
83
+ colorSaturation: waveApplier((w) => w.colorSaturation, (v, a) => {
84
+ a.u.uSaturation.value = v;
85
+ }),
86
+ opacity: waveApplier((w) => w.opacity, (v, a) => {
87
+ a.u.uOpacity.value = v;
88
+ }),
89
+ lineThickness: waveApplier((w) => w.lineThickness ?? 1, (v, a) => {
90
+ a.u.uLineThickness.value = v;
91
+ }),
92
+ lineAmount: waveApplier((w) => w.lineAmount ?? 425, (v, a) => {
93
+ a.u.uLineAmount.value = v;
94
+ }),
95
+ fiberStrength: waveApplier((w) => w.fiberStrength, (v, a) => {
96
+ a.u.uFiberStrength.value = v;
97
+ }),
98
+ sheen: waveApplier((w) => w.sheen ?? 1, (v, a) => {
99
+ a.u.uSheen.value = v;
100
+ }),
101
+ iridescence: waveApplier((w) => w.iridescence ?? 0, (v, a) => {
102
+ a.u.uIridescence.value = v;
103
+ }),
104
+ positionX: waveApplier((w) => w.position.x, (v, a) => {
105
+ a.mesh.position.x = v;
106
+ }),
107
+ positionY: waveApplier((w) => w.position.y, (v, a) => {
108
+ a.mesh.position.y = v;
109
+ })
110
+ };
111
+ /** Scene-level binding targets. base() mirrors updateTime() / applyZoom() / applyPost() fallbacks. */
112
+ const SCENE_APPLIERS = {
113
+ timeOffset: sceneApplier((c) => c.timeOffset ?? 0, (v, a) => {
114
+ a.out.timeOffset = v;
115
+ }),
116
+ cameraZoom: sceneApplier((c) => c.cameraZoom ?? 1, (v, a) => {
117
+ a.out.zoom = v;
118
+ }),
119
+ blur: sceneApplier((c) => c.blur, (v, a) => {
120
+ a.post.uBlurAmount.value = v;
121
+ }),
122
+ grain: sceneApplier((c) => c.grain, (v, a) => {
123
+ a.post.uGrainAmount.value = v;
124
+ })
125
+ };
126
+ /** The global master switch: only `scene.interaction.enabled === false` turns the whole layer off. */
127
+ function notDisabled(cfg) {
128
+ return cfg.interaction?.enabled !== false;
129
+ }
130
+ /** Whether a wave has a pointer field (hover effects, or a click ripple). */
131
+ function waveHasPointerField(w) {
132
+ const it = w.interaction;
133
+ return !!it && (!!it.hover || (it.press?.ripple ?? 0) > 0);
134
+ }
135
+ /** Whether this wave has an active pointer field → its POINTER_FX shader path compiles. */
136
+ function wavePointerFxActive(cfg, w) {
137
+ return notDisabled(cfg) && waveHasPointerField(w);
138
+ }
139
+ /** Whether this wave has active click ripples → its nested POINTER_RIPPLES path compiles. */
140
+ function waveRipplesActive(cfg, w) {
141
+ return notDisabled(cfg) && (w.interaction?.press?.ripple ?? 0) > 0;
142
+ }
143
+ /** Whether ANY wave has a pointer field (so the renderer bothers writing the shared pointer uniforms). */
144
+ function anyPointerFxActive(cfg) {
145
+ return notDisabled(cfg) && cfg.waves.some(waveHasPointerField);
146
+ }
147
+ /** Whether the interaction layer should run at all (any wave interaction, or any scene binding). */
148
+ function interactionActive(cfg) {
149
+ if (!notDisabled(cfg)) return false;
150
+ if ((cfg.interaction?.bindings?.length ?? 0) > 0) return true;
151
+ return cfg.waves.some((w) => {
152
+ const it = w.interaction;
153
+ return !!it && (!!it.hover || (it.press?.ripple ?? 0) > 0 || (it.bindings?.length ?? 0) > 0);
154
+ });
155
+ }
156
+ /**
157
+ * Owns the one cursor's input + scroll + press/appear/custom and all smoothing. Constructed by the
158
+ * renderer when {@link interactionActive} first turns true, disposed when it turns false. All
159
+ * listeners are passive and container-scoped (the poster overlay passes events through).
160
+ */
161
+ var InteractionController = class {
162
+ container;
163
+ cfg;
164
+ /** Studio-only scroll preview: when non-null, overrides the computed scroll progress. */
165
+ scrollOverride = null;
166
+ ndc = new THREE.Vector2();
167
+ ndcTarget = new THREE.Vector2();
168
+ ndcPrev = new THREE.Vector2();
169
+ velNdc = new THREE.Vector2();
170
+ presence = 0;
171
+ presenceTarget = 0;
172
+ press = 0;
173
+ pressTarget = 0;
174
+ pointerSpeed = 0;
175
+ scroll = 0;
176
+ scrollPrev = 0;
177
+ scrollVel = 0;
178
+ appearLatched = false;
179
+ customInputs = /* @__PURE__ */ new Map();
180
+ ripples = [];
181
+ fields = [];
182
+ bindingState = /* @__PURE__ */ new Map();
183
+ seenBindings = /* @__PURE__ */ new Set();
184
+ out;
185
+ constructor(container, cfg) {
186
+ this.container = container;
187
+ this.cfg = cfg;
188
+ for (let i = 0; i < 4; i++) this.ripples.push({
189
+ origin: new THREE.Vector2(),
190
+ age: 0,
191
+ amp: 0,
192
+ active: false
193
+ });
194
+ this.out = {
195
+ ndc: this.ndc,
196
+ presence: 0,
197
+ ripples: this.ripples
198
+ };
199
+ const opts = { passive: true };
200
+ container.addEventListener("pointerenter", this.onPointerEnter, opts);
201
+ container.addEventListener("pointermove", this.onPointerMove, opts);
202
+ container.addEventListener("pointerleave", this.onPointerLeave, opts);
203
+ container.addEventListener("pointercancel", this.onPointerCancel, opts);
204
+ container.addEventListener("pointerdown", this.onPointerDown, opts);
205
+ container.addEventListener("pointerup", this.onPointerUp, opts);
206
+ }
207
+ /** Ignore coarse (touch) pointers unless the scene opts in with interaction.touch. */
208
+ ignore(e) {
209
+ return e.pointerType === "touch" && this.cfg()?.interaction?.touch !== true;
210
+ }
211
+ setNdcTarget(e) {
212
+ const rect = this.container.getBoundingClientRect();
213
+ if (rect.width <= 0 || rect.height <= 0) return;
214
+ this.ndcTarget.set((e.clientX - rect.left) / rect.width * 2 - 1, -((e.clientY - rect.top) / rect.height * 2 - 1));
215
+ }
216
+ onPointerEnter = (e) => {
217
+ if (this.ignore(e)) return;
218
+ this.presenceTarget = 1;
219
+ this.setNdcTarget(e);
220
+ };
221
+ onPointerMove = (e) => {
222
+ if (this.ignore(e)) return;
223
+ if (e.pointerType === "touch" && this.pressTarget < .5) return;
224
+ this.presenceTarget = 1;
225
+ this.setNdcTarget(e);
226
+ };
227
+ onPointerLeave = (e) => {
228
+ if (this.ignore(e)) return;
229
+ this.presenceTarget = 0;
230
+ this.ndcTarget.set(0, 0);
231
+ };
232
+ onPointerCancel = (e) => {
233
+ if (this.ignore(e)) return;
234
+ this.pressTarget = 0;
235
+ this.presenceTarget = 0;
236
+ this.ndcTarget.set(0, 0);
237
+ };
238
+ onPointerDown = (e) => {
239
+ if (this.ignore(e)) return;
240
+ this.pressTarget = 1;
241
+ this.presenceTarget = 1;
242
+ this.setNdcTarget(e);
243
+ const cfg = this.cfg();
244
+ if (cfg && cfg.waves.some((w) => (w.interaction?.press?.ripple ?? 0) > 0)) this.spawnRipple();
245
+ };
246
+ onPointerUp = (e) => {
247
+ if (this.ignore(e)) return;
248
+ this.pressTarget = 0;
249
+ if (e.pointerType === "touch") {
250
+ this.presenceTarget = 0;
251
+ this.ndcTarget.set(0, 0);
252
+ }
253
+ };
254
+ /** Spawn a normalized ripple (envelope 0..1) at the click NDC; per-wave amplitude scales it in the
255
+ * shader. Reuses a free slot or evicts the oldest. */
256
+ spawnRipple() {
257
+ let slot = this.ripples.find((r) => !r.active);
258
+ if (!slot) {
259
+ slot = this.ripples[0];
260
+ for (const r of this.ripples) if (r.age > slot.age) slot = r;
261
+ }
262
+ slot.origin.copy(this.ndcTarget);
263
+ slot.age = 0;
264
+ slot.amp = 1;
265
+ slot.active = true;
266
+ }
267
+ /** Advance all smoothed state by `dt` seconds. Called from the render loop with the same delta. */
268
+ update(dt) {
269
+ const cfg = this.cfg();
270
+ if (!cfg) return;
271
+ const d = Math.max(dt, 0);
272
+ const kPointer = alpha(DEFAULT_POINTER_TAU, d);
273
+ this.ndcPrev.copy(this.ndc);
274
+ this.ndc.lerp(this.ndcTarget, kPointer);
275
+ this.presence += (this.presenceTarget - this.presence) * kPointer;
276
+ this.press += (this.pressTarget - this.press) * kPointer;
277
+ if (d > 1e-5) {
278
+ const kv = alpha(VELOCITY_TAU, d);
279
+ this.velNdc.x += ((this.ndc.x - this.ndcPrev.x) / d - this.velNdc.x) * kv;
280
+ this.velNdc.y += ((this.ndc.y - this.ndcPrev.y) / d - this.velNdc.y) * kv;
281
+ }
282
+ this.pointerSpeed = this.presence * clamp01(this.velNdc.length() / POINTER_SPEED_REF);
283
+ const waves = cfg.waves;
284
+ if (this.fields.length > waves.length) this.fields.length = waves.length;
285
+ for (let i = 0; i < waves.length; i++) {
286
+ let f = this.fields[i];
287
+ if (!f) {
288
+ f = {
289
+ ndc: this.ndcTarget.clone(),
290
+ vel: new THREE.Vector2(),
291
+ presence: this.presenceTarget
292
+ };
293
+ this.fields[i] = f;
294
+ }
295
+ const tau = Math.max(waves[i].interaction?.hover?.smoothing ?? DEFAULT_POINTER_TAU, MIN_POINTER_TAU);
296
+ springVec2(f.ndc, f.vel, this.ndcTarget, 1 / tau, POINTER_SPRING_ZETA, d);
297
+ f.presence += (this.presenceTarget - f.presence) * alpha(tau, d);
298
+ }
299
+ const rawScroll = this.scrollOverride ?? this.computeScroll();
300
+ if (d > 1e-5) {
301
+ const sv = Math.abs(rawScroll - this.scrollPrev) / d;
302
+ this.scrollVel += (sv - this.scrollVel) * alpha(SCROLL_VELOCITY_TAU, d);
303
+ }
304
+ this.scrollPrev = rawScroll;
305
+ this.scroll = rawScroll;
306
+ this.appearLatched = true;
307
+ for (const r of this.ripples) {
308
+ if (!r.active) continue;
309
+ r.age += d;
310
+ const env = Math.max(0, 1 - r.age / RIPPLE_LIFETIME);
311
+ r.amp = env * env;
312
+ if (r.amp <= 0) r.active = false;
313
+ }
314
+ this.updateBindings(cfg, d);
315
+ }
316
+ updateBindings(cfg, dt) {
317
+ const seen = this.seenBindings;
318
+ seen.clear();
319
+ const sceneBindings = cfg.interaction?.bindings;
320
+ if (sceneBindings) for (let i = 0; i < sceneBindings.length; i++) this.advanceBinding(sceneBindings[i], dt);
321
+ for (let w = 0; w < cfg.waves.length; w++) {
322
+ const bindings = cfg.waves[w].interaction?.bindings;
323
+ if (!bindings) continue;
324
+ for (let i = 0; i < bindings.length; i++) this.advanceBinding(bindings[i], dt);
325
+ }
326
+ if (this.bindingState.size > seen.size) {
327
+ for (const key of this.bindingState.keys()) if (!seen.has(key)) this.bindingState.delete(key);
328
+ }
329
+ }
330
+ /** Advance one binding's smoothed source value by `dt` and mark it live in `seenBindings`. */
331
+ advanceBinding(b, dt) {
332
+ this.seenBindings.add(b);
333
+ const raw = this.rawSource(b.source);
334
+ let st = this.bindingState.get(b);
335
+ if (!st || st.source !== b.source) {
336
+ st = {
337
+ value: b.source === "appear" ? 0 : raw,
338
+ source: b.source
339
+ };
340
+ this.bindingState.set(b, st);
341
+ }
342
+ st.value += (raw - st.value) * alpha(b.smoothing ?? DEFAULT_BINDING_TAU, dt);
343
+ }
344
+ /** The current smoothed 0..1 value of a binding's source (0 if the binding is unknown). */
345
+ bindingValue(b) {
346
+ return this.bindingState.get(b)?.value ?? 0;
347
+ }
348
+ /** The current raw (un-per-binding-smoothed) 0..1 value of a source signal. */
349
+ rawSource(source) {
350
+ switch (source) {
351
+ case "scroll": return this.scroll;
352
+ case "hover": return this.presence;
353
+ case "pointerX": return (this.ndc.x + 1) * .5;
354
+ case "pointerY": return (this.ndc.y + 1) * .5;
355
+ case "pointerSpeed": return this.pointerSpeed;
356
+ case "press": return this.press;
357
+ case "scrollVelocity": return clamp01(this.scrollVel / SCROLL_VELOCITY_REF);
358
+ case "appear": return this.appearLatched ? 1 : 0;
359
+ default: return this.customInputs.get(source.slice(7)) ?? 0;
360
+ }
361
+ }
362
+ /** Container progress through the viewport: 0 as it enters from below, 1 once scrolled past. */
363
+ computeScroll() {
364
+ const rect = this.container.getBoundingClientRect();
365
+ const vh = window.innerHeight || document.documentElement.clientHeight || 1;
366
+ return clamp01((vh - rect.top) / (vh + rect.height));
367
+ }
368
+ /** The shared pointer-field state + ripples for the renderer (live references — read synchronously). */
369
+ sample() {
370
+ this.out.presence = this.presence;
371
+ return this.out;
372
+ }
373
+ /** This wave's smoothed pointer-field state (it trails the cursor at its own hover smoothing), or
374
+ * null if the wave hasn't been advanced by update() yet (treat as rest). */
375
+ fieldFor(waveIdx) {
376
+ return this.fields[waveIdx] ?? null;
377
+ }
378
+ /** Velocity-driven agitation drive 0..1 (how fast the cursor is moving, presence-gated). The
379
+ * renderer scales each wave's hover `agitate` by this, so the churn tracks the gesture instead
380
+ * of buzzing at a constant rate whenever the cursor is merely present. */
381
+ pointerFlux() {
382
+ return this.pointerSpeed;
383
+ }
384
+ /** Smoothed pointer velocity, NDC/s (direction + speed of the drag). The renderer feeds it to the
385
+ * drag-wake shader so the trailing trough forms behind the motion. Live reference — read per frame. */
386
+ pointerVelocity() {
387
+ return this.velNdc;
388
+ }
389
+ /** Feed a `custom:<name>` input (developer API; staged/forwarded by the shell). */
390
+ setInput(name, value) {
391
+ if (typeof name !== "string" || !Number.isFinite(value)) return;
392
+ this.customInputs.set(name, value);
393
+ }
394
+ /**
395
+ * Collapse to the settled resting state for the single frame drawn when the loop stops (paused /
396
+ * reduced-motion / offscreen): presence / velocity / press / pointerSpeed → 0, ripples cleared,
397
+ * scroll → its current raw value, pointer → centre, and `appear` → 1 (reduced-motion users must
398
+ * see the FINAL entered state). Custom inputs KEEP their last explicit values. Each binding snaps
399
+ * to its settled source so the one settled frame shows the final look.
400
+ */
401
+ settle() {
402
+ this.presence = this.presenceTarget = 0;
403
+ this.press = this.pressTarget = 0;
404
+ this.pointerSpeed = 0;
405
+ this.velNdc.set(0, 0);
406
+ this.ndc.set(0, 0);
407
+ this.ndcTarget.set(0, 0);
408
+ this.ndcPrev.set(0, 0);
409
+ for (const f of this.fields) {
410
+ f.ndc.set(0, 0);
411
+ f.vel.set(0, 0);
412
+ f.presence = 0;
413
+ }
414
+ for (const r of this.ripples) {
415
+ r.age = 0;
416
+ r.amp = 0;
417
+ r.active = false;
418
+ }
419
+ const rawScroll = this.scrollOverride ?? this.computeScroll();
420
+ this.scroll = this.scrollPrev = rawScroll;
421
+ this.scrollVel = 0;
422
+ this.appearLatched = true;
423
+ const cfg = this.cfg();
424
+ if (cfg) {
425
+ this.bindingState.clear();
426
+ const snap = (b) => {
427
+ this.bindingState.set(b, {
428
+ value: this.rawSource(b.source),
429
+ source: b.source
430
+ });
431
+ };
432
+ for (const b of cfg.interaction?.bindings ?? []) snap(b);
433
+ for (const w of cfg.waves) for (const b of w.interaction?.bindings ?? []) snap(b);
434
+ }
435
+ }
436
+ /**
437
+ * Snap scroll progress + the scroll-sourced bindings to the current override at once, leaving
438
+ * every other input (pointer / press / appear / velocity / custom) advancing live. Used by the
439
+ * studio scroll preview: the studio page never really scrolls, so dragging the preview slider is a
440
+ * manual scrub that must reflect the instant you move it — not on the next animation frame, which
441
+ * the browser fully suspends whenever the tab isn't foreground. Unlike settle() (which collapses
442
+ * ALL input to rest for a paused still frame), this touches only the scroll signal.
443
+ */
444
+ snapScroll() {
445
+ const raw = this.scrollOverride ?? this.computeScroll();
446
+ this.scroll = this.scrollPrev = raw;
447
+ this.scrollVel = 0;
448
+ const cfg = this.cfg();
449
+ if (!cfg) return;
450
+ const snap = (b) => {
451
+ if (b.source === "scroll" || b.source === "scrollVelocity") this.bindingState.set(b, {
452
+ value: this.rawSource(b.source),
453
+ source: b.source
454
+ });
455
+ };
456
+ for (const b of cfg.interaction?.bindings ?? []) snap(b);
457
+ for (const w of cfg.waves) for (const b of w.interaction?.bindings ?? []) snap(b);
458
+ }
459
+ dispose() {
460
+ const c = this.container;
461
+ c.removeEventListener("pointerenter", this.onPointerEnter);
462
+ c.removeEventListener("pointermove", this.onPointerMove);
463
+ c.removeEventListener("pointerleave", this.onPointerLeave);
464
+ c.removeEventListener("pointercancel", this.onPointerCancel);
465
+ c.removeEventListener("pointerdown", this.onPointerDown);
466
+ c.removeEventListener("pointerup", this.onPointerUp);
467
+ this.customInputs.clear();
468
+ this.bindingState.clear();
469
+ }
470
+ };
471
+ //#endregion
472
+ export { InteractionController, SCENE_APPLIERS, WAVE_APPLIERS, anyPointerFxActive, interactionActive, wavePointerFxActive, waveRipplesActive };
473
+
474
+ //# sourceMappingURL=interaction.js.map