@wave3d/core 0.7.0 → 0.9.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.
Files changed (75) hide show
  1. package/README.md +3 -1
  2. package/dist/config/model.d.ts +78 -4
  3. package/dist/config/model.js +12 -2
  4. package/dist/config/model.js.map +1 -1
  5. package/dist/index.d.ts +3 -2
  6. package/dist/presets.js +35 -0
  7. package/dist/presets.js.map +1 -1
  8. package/dist/renderer/WaveRenderer.d.ts +146 -12
  9. package/dist/renderer/WaveRenderer.js +218 -56
  10. package/dist/renderer/WaveRenderer.js.map +1 -1
  11. package/dist/renderer/WaveRendererGPU.js +291 -0
  12. package/dist/renderer/WaveRendererGPU.js.map +1 -0
  13. package/dist/renderer/gpu-loader.js +2 -0
  14. package/dist/renderer/index.d.ts +3 -2
  15. package/dist/renderer/interaction.d.ts +29 -0
  16. package/dist/renderer/interaction.js +77 -31
  17. package/dist/renderer/interaction.js.map +1 -1
  18. package/dist/renderer/interactionGates.js +59 -0
  19. package/dist/renderer/interactionGates.js.map +1 -0
  20. package/dist/renderer/particleField.d.ts +1 -28
  21. package/dist/renderer/particleField.js +173 -16
  22. package/dist/renderer/particleField.js.map +1 -1
  23. package/dist/renderer/particleFieldGPU.js +141 -0
  24. package/dist/renderer/particleFieldGPU.js.map +1 -0
  25. package/dist/renderer/shaders.js +178 -86
  26. package/dist/renderer/shaders.js.map +1 -1
  27. package/dist/renderer/tilt.d.ts +17 -0
  28. package/dist/renderer/tilt.js +124 -0
  29. package/dist/renderer/tilt.js.map +1 -0
  30. package/dist/renderer/tsl/color.js +129 -0
  31. package/dist/renderer/tsl/color.js.map +1 -0
  32. package/dist/renderer/tsl/noise.js +83 -0
  33. package/dist/renderer/tsl/noise.js.map +1 -0
  34. package/dist/renderer/tsl/packedArray.js +76 -0
  35. package/dist/renderer/tsl/packedArray.js.map +1 -0
  36. package/dist/renderer/tsl/particleMaterial.js +133 -0
  37. package/dist/renderer/tsl/particleMaterial.js.map +1 -0
  38. package/dist/renderer/tsl/particleUniforms.js +44 -0
  39. package/dist/renderer/tsl/particleUniforms.js.map +1 -0
  40. package/dist/renderer/tsl/pointerField.js +73 -0
  41. package/dist/renderer/tsl/pointerField.js.map +1 -0
  42. package/dist/renderer/tsl/post.js +63 -0
  43. package/dist/renderer/tsl/post.js.map +1 -0
  44. package/dist/renderer/tsl/postChain.js +56 -0
  45. package/dist/renderer/tsl/postChain.js.map +1 -0
  46. package/dist/renderer/tsl/postEffects.js +221 -0
  47. package/dist/renderer/tsl/postEffects.js.map +1 -0
  48. package/dist/renderer/tsl/types.js +28 -0
  49. package/dist/renderer/tsl/types.js.map +1 -0
  50. package/dist/renderer/tsl/uniforms.js +152 -0
  51. package/dist/renderer/tsl/uniforms.js.map +1 -0
  52. package/dist/renderer/tsl/waveMaterial.js +185 -0
  53. package/dist/renderer/tsl/waveMaterial.js.map +1 -0
  54. package/dist/renderer/tsl/waveShape.js +108 -0
  55. package/dist/renderer/tsl/waveShape.js.map +1 -0
  56. package/dist/shell/createWave.d.ts +29 -0
  57. package/dist/shell/createWave.js +39 -11
  58. package/dist/shell/createWave.js.map +1 -1
  59. package/dist/shell/probe.js +18 -1
  60. package/dist/shell/probe.js.map +1 -1
  61. package/dist/standalone/wave3d.standalone.js +2219 -1929
  62. package/dist/standalone/wave3d.standalone.webgpu.js +36647 -0
  63. package/dist/standalone.d.ts +10 -3
  64. package/dist/standalone.js +10 -3
  65. package/dist/standalone.js.map +1 -1
  66. package/dist/studio/StudioWaveRenderer.d.ts +4 -0
  67. package/dist/studio/StudioWaveRenderer.js +9 -0
  68. package/dist/studio/StudioWaveRenderer.js.map +1 -1
  69. package/dist/studio/StudioWaveRendererGPU.js +18 -0
  70. package/dist/studio/StudioWaveRendererGPU.js.map +1 -0
  71. package/dist/studio/index.d.ts +12 -2
  72. package/dist/studio/index.js +14 -1
  73. package/dist/studio/index.js.map +1 -0
  74. package/package.json +12 -3
  75. package/skills/wave3d/SKILL.md +29 -2
@@ -1 +1 @@
1
- {"version":3,"file":"interaction.js","names":[],"sources":["../../src/renderer/interaction.ts"],"sourcesContent":["// The optional interactivity runtime for the wave renderer: a per-wave pointer field (localized\n// cursor effects) + per-wave and scene input→param bindings, driven by ONE shared cursor/scroll.\n// It lives in renderer/ so it stays below the shell/studio/index layers (depcruise); it may import\n// only `three`, ../config/model, and ../util/math.\n//\n// Split of responsibility with WaveRenderer: this controller owns ALL input + smoothing (the one\n// cursor's position / presence / press / velocity, scroll progress + velocity, the `appear` latch,\n// custom inputs, click ripples, and every binding's smoothed 0..1 source value — keyed by binding\n// identity so scene + per-wave binding lists all get their own smoothing). The renderer reads\n// sample() / bindingValue() once per frame and writes uniforms — the pointer field per wave, and\n// bindings via the WAVE_APPLIERS / SCENE_APPLIERS tables. Bindings NEVER mutate `config`.\nimport * as THREE from \"three\";\nimport { clamp01 } from \"../util/math\";\nimport type {\n InteractionSource,\n SceneInteractionBinding,\n SceneInteractionTarget,\n StudioConfig,\n WaveConfig,\n WaveInteractionBinding,\n WaveInteractionTarget,\n} from \"../config/model\";\n\n/** Click-ripple ring-buffer size. MUST match the `[4]` array sizes in shaders.ts (POINTER_RIPPLES). */\nexport const RIPPLE_SLOTS = 4;\nconst RIPPLE_LIFETIME = 1.5; // seconds a ripple lives (crest travels out + fades by then)\nconst VELOCITY_TAU = 0.08; // pointer-velocity smoothing time constant (seconds)\nconst POINTER_SPEED_REF = 4.0; // NDC/s that normalizes pointerSpeed to 1.0\nconst SCROLL_VELOCITY_REF = 2.0; // progress/s that normalizes scrollVelocity to 1.0\nconst SCROLL_VELOCITY_TAU = 0.15; // scroll-velocity smoothing (seconds)\nconst DEFAULT_POINTER_TAU = 0.12; // pointer-follow smoothing default (seconds)\nconst DEFAULT_BINDING_TAU = 0.25; // per-binding source smoothing default (seconds)\nconst POINTER_SPRING_ZETA = 0.7; // pointer-field damping ratio (<1 → slight overshoot = \"weight\")\nconst MIN_POINTER_TAU = 0.02; // floor before smoothing→spring frequency (omega = 1/tau)\nconst SPRING_MAX_STEP = 1 / 120; // substep the spring below this dt so it stays stable after a stall\nconst SPRING_MAX_SUBSTEPS = 6;\n\ntype AnyBinding = WaveInteractionBinding | SceneInteractionBinding;\n\n/** Frame-rate-independent exponential smoothing factor for time constant `tau` (seconds). */\nfunction alpha(tau: number, dt: number): number {\n return tau > 0 ? 1 - Math.exp(-dt / tau) : 1;\n}\n\n/**\n * Advance a damped spring (`pos`/`vel`) toward `target` by `dt`, using semi-implicit (symplectic)\n * Euler. `omega` is the natural angular frequency (≈ 1/response-time), `zeta` the damping ratio\n * (<1 underdamped → overshoots and settles; 1 critical; >1 sluggish). Unlike a first-order lag this\n * carries momentum, so motion has weight and settles instead of creeping to a dead stop. Substeps\n * when `dt` spikes (e.g. the tab was backgrounded) so a stiff spring can't blow up; ~1 step at 60fps.\n */\nfunction springVec2(\n pos: THREE.Vector2,\n vel: THREE.Vector2,\n target: THREE.Vector2,\n omega: number,\n zeta: number,\n dt: number,\n): void {\n if (dt <= 0) return;\n const steps =\n dt > SPRING_MAX_STEP ? Math.min(Math.ceil(dt / SPRING_MAX_STEP), SPRING_MAX_SUBSTEPS) : 1;\n const h = dt / steps;\n const k = omega * omega;\n const c = 2 * zeta * omega;\n for (let s = 0; s < steps; s++) {\n vel.x += (k * (target.x - pos.x) - c * vel.x) * h;\n vel.y += (k * (target.y - pos.y) - c * vel.y) * h;\n pos.x += vel.x * h;\n pos.y += vel.y * h;\n }\n}\n\n// ---- Binding applier tables -----------------------------------------------------------------\n\n/** What a wave-scoped applier writes into: one wave's uniforms + its mesh transform. */\nexport interface WaveApplyArgs {\n u: Record<string, THREE.IUniform>;\n mesh: THREE.Object3D;\n}\n/** What a scene-scoped applier writes into: the post-pass uniforms + a small out-param the renderer\n * seeds (0 / 1) each frame and reads back (the interaction time-offset + zoom multiplier). */\nexport interface SceneApplyArgs {\n post: Record<string, THREE.IUniform>;\n out: { timeOffset: number; zoom: number };\n}\ninterface WaveApplier {\n base(w: WaveConfig): number;\n apply(value: number, a: WaveApplyArgs): void;\n}\ninterface SceneApplier {\n base(c: StudioConfig): number;\n apply(value: number, a: SceneApplyArgs): void;\n}\n\nconst waveApplier = (\n base: (w: WaveConfig) => number,\n apply: (value: number, a: WaveApplyArgs) => void,\n): WaveApplier => ({ base, apply });\nconst sceneApplier = (\n base: (c: StudioConfig) => number,\n apply: (value: number, a: SceneApplyArgs) => void,\n): SceneApplier => ({ base, apply });\n\n/**\n * Per-wave binding targets → (how to read the authored base value, how to write the modulated one).\n * Each base() mirrors the exact fallback refresh() uses, so a binding at rest (from omitted, source\n * 0) writes the same value the renderer already had — no visible jump. This object is the runtime\n * source of truth for {@link WaveInteractionTarget} (enforced by `satisfies`).\n */\nexport const WAVE_APPLIERS = {\n displaceAmount: waveApplier(\n (w) => w.displaceAmount,\n (v, a) => {\n a.u.uDispAmount.value = v;\n },\n ),\n detailAmount: waveApplier(\n (w) => w.detailAmount ?? 0,\n (v, a) => {\n a.u.uDetailAmount.value = v;\n },\n ),\n twistPowerX: waveApplier(\n (w) => w.twistPower.x,\n (v, a) => {\n a.u.uTwPowX.value = v;\n },\n ),\n twistPowerY: waveApplier(\n (w) => w.twistPower.y,\n (v, a) => {\n a.u.uTwPowY.value = v;\n },\n ),\n twistPowerZ: waveApplier(\n (w) => w.twistPower.z,\n (v, a) => {\n a.u.uTwPowZ.value = v;\n },\n ),\n twistFrequencyX: waveApplier(\n (w) => w.twistFrequency.x,\n (v, a) => {\n a.u.uTwFreqX.value = v;\n },\n ),\n twistFrequencyY: waveApplier(\n (w) => w.twistFrequency.y,\n (v, a) => {\n a.u.uTwFreqY.value = v;\n },\n ),\n twistFrequencyZ: waveApplier(\n (w) => w.twistFrequency.z,\n (v, a) => {\n a.u.uTwFreqZ.value = v;\n },\n ),\n hueShift: waveApplier(\n (w) => w.hueShift,\n (v, a) => {\n a.u.uHueShift.value = v;\n },\n ),\n gradientShift: waveApplier(\n (w) => w.gradientShift ?? 0,\n (v, a) => {\n a.u.uGradShift.value = v;\n },\n ),\n colorSaturation: waveApplier(\n (w) => w.colorSaturation,\n (v, a) => {\n a.u.uSaturation.value = v;\n },\n ),\n opacity: waveApplier(\n (w) => w.opacity,\n (v, a) => {\n a.u.uOpacity.value = v;\n },\n ),\n lineThickness: waveApplier(\n (w) => w.lineThickness ?? 1,\n (v, a) => {\n a.u.uLineThickness.value = v;\n },\n ),\n lineAmount: waveApplier(\n (w) => w.lineAmount ?? 425,\n (v, a) => {\n a.u.uLineAmount.value = v;\n },\n ),\n fiberStrength: waveApplier(\n (w) => w.fiberStrength,\n (v, a) => {\n a.u.uFiberStrength.value = v;\n },\n ),\n sheen: waveApplier(\n (w) => w.sheen ?? 1,\n (v, a) => {\n a.u.uSheen.value = v;\n },\n ),\n iridescence: waveApplier(\n (w) => w.iridescence ?? 0,\n (v, a) => {\n a.u.uIridescence.value = v;\n },\n ),\n // Helix: phase spins the coil, turns winds/unwinds it, radius opens and closes it. All three are\n // inert unless the wave already has a helix, so waveDefines compiles HELIX for a wave that binds\n // one but authors radius/roll at 0 (same reason detailAmount does — see bindsDetail there).\n helixPhase: waveApplier(\n (w) => w.helixPhase ?? 0,\n (v, a) => {\n a.u.uHelixPhase.value = v;\n },\n ),\n helixTurns: waveApplier(\n (w) => w.helixTurns ?? 0,\n (v, a) => {\n a.u.uHelixTurns.value = v;\n },\n ),\n helixRadius: waveApplier(\n (w) => w.helixRadius ?? 0,\n (v, a) => {\n a.u.uHelixRadius.value = v;\n },\n ),\n positionX: waveApplier(\n (w) => w.position.x,\n (v, a) => {\n a.mesh.position.x = v;\n },\n ),\n positionY: waveApplier(\n (w) => w.position.y,\n (v, a) => {\n a.mesh.position.y = v;\n },\n ),\n} satisfies Record<WaveInteractionTarget, WaveApplier>;\n\n/** Scene-level binding targets. base() mirrors updateTime() / applyZoom() / applyPost() fallbacks. */\nexport const SCENE_APPLIERS = {\n timeOffset: sceneApplier(\n (c) => c.timeOffset ?? 0,\n (v, a) => {\n a.out.timeOffset = v;\n },\n ),\n cameraZoom: sceneApplier(\n (c) => c.cameraZoom ?? 1,\n (v, a) => {\n a.out.zoom = v;\n },\n ),\n blur: sceneApplier(\n (c) => c.blur,\n (v, a) => {\n a.post.uBlurAmount.value = v;\n },\n ),\n grain: sceneApplier(\n (c) => c.grain,\n (v, a) => {\n a.post.uGrainAmount.value = v;\n },\n ),\n} satisfies Record<SceneInteractionTarget, SceneApplier>;\n\n// ---- Active-state predicates (keyed off config only, so input never triggers a recompile) ----\n\n/** The global master switch: only `scene.interaction.enabled === false` turns the whole layer off. */\nfunction notDisabled(cfg: StudioConfig): boolean {\n return cfg.interaction?.enabled !== false;\n}\n\n/** Whether a wave has a pointer field (hover effects, or a click ripple). */\nfunction waveHasPointerField(w: WaveConfig): boolean {\n const it = w.interaction;\n return !!it && (!!it.hover || (it.press?.ripple ?? 0) > 0);\n}\n\n/** Whether this wave has an active pointer field → its POINTER_FX shader path compiles. */\nexport function wavePointerFxActive(cfg: StudioConfig, w: WaveConfig): boolean {\n return notDisabled(cfg) && waveHasPointerField(w);\n}\n\n/** Whether this wave has active click ripples → its nested POINTER_RIPPLES path compiles. */\nexport function waveRipplesActive(cfg: StudioConfig, w: WaveConfig): boolean {\n return notDisabled(cfg) && (w.interaction?.press?.ripple ?? 0) > 0;\n}\n\n/** Whether ANY wave has a pointer field (so the renderer bothers writing the shared pointer uniforms). */\nexport function anyPointerFxActive(cfg: StudioConfig): boolean {\n return notDisabled(cfg) && cfg.waves.some(waveHasPointerField);\n}\n\n/** Whether the interaction layer should run at all (any wave interaction, or any scene binding). */\nexport function interactionActive(cfg: StudioConfig): boolean {\n if (!notDisabled(cfg)) return false;\n if ((cfg.interaction?.bindings?.length ?? 0) > 0) return true;\n return cfg.waves.some((w) => {\n const it = w.interaction;\n return !!it && (!!it.hover || (it.press?.ripple ?? 0) > 0 || (it.bindings?.length ?? 0) > 0);\n });\n}\n\n// ---- Sample shape + the controller ----------------------------------------------------------\n\ninterface RippleSlot {\n origin: THREE.Vector2; // NDC\n age: number; // seconds since spawn\n amp: number; // 0..1 decay envelope (0 = free slot)\n}\ninterface RippleState extends RippleSlot {\n active: boolean;\n}\n\n/** A per-frame snapshot of the pointer-field state. Fields are LIVE references into the controller's\n * state — read them synchronously each frame; don't retain them. */\nexport interface InteractionSample {\n /** Smoothed pointer position, NDC (-1..1). */\n ndc: THREE.Vector2;\n /** Smoothed pointer presence 0..1 (→ uPointerActive). */\n presence: number;\n /** Click-ripple ring buffer (amp = shared 0..1 envelope; 0 = free slot). */\n ripples: readonly RippleSlot[];\n}\n\n/** A wave's own smoothed pointer-field state — trails the shared cursor at the wave's own rate. */\nexport interface PointerField {\n /** Smoothed pointer position for this wave, NDC (-1..1). */\n ndc: THREE.Vector2;\n /** Spring velocity of `ndc` (NDC/s) — internal spring state, not read by the renderer. */\n vel: THREE.Vector2;\n /** Smoothed pointer presence 0..1 for this wave. */\n presence: number;\n}\n\n/**\n * Owns the one cursor's input + scroll + press/appear/custom and all smoothing. Constructed by the\n * renderer when {@link interactionActive} first turns true, disposed when it turns false. All\n * listeners are passive and container-scoped (the poster overlay passes events through).\n */\nexport class InteractionController {\n /** Studio-only scroll preview: when non-null, overrides the computed scroll progress. */\n scrollOverride: number | null = null;\n\n private readonly ndc = new THREE.Vector2();\n private readonly ndcTarget = new THREE.Vector2();\n private readonly ndcPrev = new THREE.Vector2();\n private readonly velNdc = new THREE.Vector2();\n private presence = 0;\n private presenceTarget = 0;\n private press = 0;\n private pressTarget = 0;\n private pointerSpeed = 0;\n private scroll = 0;\n private scrollPrev = 0;\n private scrollVel = 0;\n private appearLatched = false;\n private readonly customInputs = new Map<string, number>();\n private readonly ripples: RippleState[] = [];\n // Per-wave pointer-field state (index-parallel to config.waves); each trails the cursor at its own\n // hover smoothing. Grown/shrunk in update().\n private readonly fields: PointerField[] = [];\n // Per-binding smoothing state, keyed by binding-object identity (covers scene + every wave list).\n private readonly bindingState = new Map<\n AnyBinding,\n { value: number; source: InteractionSource }\n >();\n // Scratch set reused by updateBindings every frame (cleared, never reallocated).\n private readonly seenBindings = new Set<AnyBinding>();\n private readonly out: InteractionSample;\n\n constructor(\n private readonly container: HTMLElement,\n private readonly cfg: () => StudioConfig | undefined,\n ) {\n for (let i = 0; i < RIPPLE_SLOTS; i++) {\n this.ripples.push({ origin: new THREE.Vector2(), age: 0, amp: 0, active: false });\n }\n this.out = { ndc: this.ndc, presence: 0, ripples: this.ripples };\n const opts = { passive: true } as const;\n container.addEventListener(\"pointerenter\", this.onPointerEnter, opts);\n container.addEventListener(\"pointermove\", this.onPointerMove, opts);\n container.addEventListener(\"pointerleave\", this.onPointerLeave, opts);\n container.addEventListener(\"pointercancel\", this.onPointerCancel, opts);\n container.addEventListener(\"pointerdown\", this.onPointerDown, opts);\n container.addEventListener(\"pointerup\", this.onPointerUp, opts);\n }\n\n /** Ignore coarse (touch) pointers unless the scene opts in with interaction.touch. */\n private ignore(e: PointerEvent): boolean {\n return e.pointerType === \"touch\" && this.cfg()?.interaction?.touch !== true;\n }\n\n private setNdcTarget(e: PointerEvent): void {\n const rect = this.container.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n this.ndcTarget.set(\n ((e.clientX - rect.left) / rect.width) * 2 - 1,\n -(((e.clientY - rect.top) / rect.height) * 2 - 1),\n );\n }\n\n private onPointerEnter = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n this.presenceTarget = 1;\n this.setNdcTarget(e);\n };\n private onPointerMove = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n if (e.pointerType === \"touch\" && this.pressTarget < 0.5) return; // touch: only track while down\n this.presenceTarget = 1;\n this.setNdcTarget(e);\n };\n private onPointerLeave = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n this.presenceTarget = 0;\n this.ndcTarget.set(0, 0); // relax toward centre → pointerX/Y rest at 0.5\n };\n private onPointerCancel = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n this.pressTarget = 0;\n this.presenceTarget = 0;\n this.ndcTarget.set(0, 0);\n };\n private onPointerDown = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n this.pressTarget = 1;\n this.presenceTarget = 1;\n this.setNdcTarget(e);\n // Spawn a ripple only if some wave actually wants ripples (else it is wasted state).\n const cfg = this.cfg();\n if (cfg && cfg.waves.some((w) => (w.interaction?.press?.ripple ?? 0) > 0)) this.spawnRipple();\n };\n private onPointerUp = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n this.pressTarget = 0;\n if (e.pointerType === \"touch\") {\n this.presenceTarget = 0; // touch has no hover — presence ends with the touch\n this.ndcTarget.set(0, 0);\n }\n };\n\n /** Spawn a normalized ripple (envelope 0..1) at the click NDC; per-wave amplitude scales it in the\n * shader. Reuses a free slot or evicts the oldest. */\n private spawnRipple(): void {\n let slot = this.ripples.find((r) => !r.active);\n if (!slot) {\n slot = this.ripples[0];\n for (const r of this.ripples) if (r.age > slot.age) slot = r;\n }\n slot.origin.copy(this.ndcTarget);\n slot.age = 0;\n slot.amp = 1;\n slot.active = true;\n }\n\n /** Advance all smoothed state by `dt` seconds. Called from the render loop with the same delta. */\n update(dt: number): void {\n const cfg = this.cfg();\n if (!cfg) return;\n const d = Math.max(dt, 0);\n // The SHARED pointer state feeds binding sources (hover / pointerX-Y / pointerSpeed / press) at a\n // fixed baseline; each wave's FIELD trails at its own hover smoothing further below.\n const kPointer = alpha(DEFAULT_POINTER_TAU, d);\n\n // Pointer position + presence + press.\n this.ndcPrev.copy(this.ndc);\n this.ndc.lerp(this.ndcTarget, kPointer);\n this.presence += (this.presenceTarget - this.presence) * kPointer;\n this.press += (this.pressTarget - this.press) * kPointer;\n\n // Velocity (own tau) from the smoothed-position delta.\n if (d > 1e-5) {\n const kv = alpha(VELOCITY_TAU, d);\n this.velNdc.x += ((this.ndc.x - this.ndcPrev.x) / d - this.velNdc.x) * kv;\n this.velNdc.y += ((this.ndc.y - this.ndcPrev.y) / d - this.velNdc.y) * kv;\n }\n this.pointerSpeed = this.presence * clamp01(this.velNdc.length() / POINTER_SPEED_REF);\n\n // Per-wave pointer FIELD: each wave's position is a damped SPRING toward the raw cursor target,\n // so a stack trails the cursor at different rates (parallax) and — because the spring is slightly\n // underdamped — carries a little weight: it overshoots and settles instead of creeping to a dead\n // stop. Presence stays a plain ramp (a spring there could dip below 0 and invert the effect).\n // A wave's hover `smoothing` sets the spring frequency (omega = 1/tau).\n const waves = cfg.waves;\n if (this.fields.length > waves.length) this.fields.length = waves.length;\n for (let i = 0; i < waves.length; i++) {\n let f = this.fields[i];\n if (!f) {\n f = {\n ndc: this.ndcTarget.clone(),\n vel: new THREE.Vector2(),\n presence: this.presenceTarget,\n };\n this.fields[i] = f;\n }\n const tau = Math.max(\n waves[i].interaction?.hover?.smoothing ?? DEFAULT_POINTER_TAU,\n MIN_POINTER_TAU,\n );\n springVec2(f.ndc, f.vel, this.ndcTarget, 1 / tau, POINTER_SPRING_ZETA, d);\n f.presence += (this.presenceTarget - f.presence) * alpha(tau, d);\n }\n\n // Scroll progress + velocity.\n const rawScroll = this.scrollOverride ?? this.computeScroll();\n if (d > 1e-5) {\n const sv = Math.abs(rawScroll - this.scrollPrev) / d;\n this.scrollVel += (sv - this.scrollVel) * alpha(SCROLL_VELOCITY_TAU, d);\n }\n this.scrollPrev = rawScroll;\n this.scroll = rawScroll;\n\n // Appear latch: the render loop is visibility-gated, so the first update() IS first-visible.\n this.appearLatched = true;\n\n // Ripples: age + quadratic-decay envelope.\n for (const r of this.ripples) {\n if (!r.active) continue;\n r.age += d;\n const env = Math.max(0, 1 - r.age / RIPPLE_LIFETIME);\n r.amp = env * env;\n if (r.amp <= 0) r.active = false;\n }\n\n this.updateBindings(cfg, d);\n }\n\n // Indexed loops + a reused scratch set (no per-frame closure/array/Set) — this runs every frame.\n private updateBindings(cfg: StudioConfig, dt: number): void {\n const seen = this.seenBindings;\n seen.clear();\n const sceneBindings = cfg.interaction?.bindings;\n if (sceneBindings) {\n for (let i = 0; i < sceneBindings.length; i++) this.advanceBinding(sceneBindings[i], dt);\n }\n for (let w = 0; w < cfg.waves.length; w++) {\n const bindings = cfg.waves[w].interaction?.bindings;\n if (!bindings) continue;\n for (let i = 0; i < bindings.length; i++) this.advanceBinding(bindings[i], dt);\n }\n // Prune state for bindings that no longer exist (edited/removed slots). advanceBinding puts every\n // seen binding in the map, so map ⊇ seen — equal sizes means nothing is stale to walk for.\n if (this.bindingState.size > seen.size) {\n for (const key of this.bindingState.keys()) if (!seen.has(key)) this.bindingState.delete(key);\n }\n }\n\n /** Advance one binding's smoothed source value by `dt` and mark it live in `seenBindings`. */\n private advanceBinding(b: AnyBinding, dt: number): void {\n this.seenBindings.add(b);\n const raw = this.rawSource(b.source);\n let st = this.bindingState.get(b);\n // (Re)initialise on first sight or when the slot's source changed (studio edit): `appear`\n // ramps from 0 (entrance), every other source snaps to its current value.\n if (!st || st.source !== b.source) {\n st = { value: b.source === \"appear\" ? 0 : raw, source: b.source };\n this.bindingState.set(b, st);\n }\n st.value += (raw - st.value) * alpha(b.smoothing ?? DEFAULT_BINDING_TAU, dt);\n }\n\n /** The current smoothed 0..1 value of a binding's source (0 if the binding is unknown). */\n bindingValue(b: AnyBinding): number {\n return this.bindingState.get(b)?.value ?? 0;\n }\n\n /** The current raw (un-per-binding-smoothed) 0..1 value of a source signal. */\n private rawSource(source: InteractionSource): number {\n switch (source) {\n case \"scroll\":\n return this.scroll;\n case \"hover\":\n return this.presence;\n case \"pointerX\":\n return (this.ndc.x + 1) * 0.5;\n case \"pointerY\":\n return (this.ndc.y + 1) * 0.5;\n case \"pointerSpeed\":\n return this.pointerSpeed;\n case \"press\":\n return this.press;\n case \"scrollVelocity\":\n return clamp01(this.scrollVel / SCROLL_VELOCITY_REF);\n case \"appear\":\n return this.appearLatched ? 1 : 0;\n default:\n // custom:<name> — fed by setInput(name, value).\n return this.customInputs.get(source.slice(\"custom:\".length)) ?? 0;\n }\n }\n\n /** Container progress through the viewport: 0 as it enters from below, 1 once scrolled past. */\n private computeScroll(): number {\n const rect = this.container.getBoundingClientRect();\n const vh = window.innerHeight || document.documentElement.clientHeight || 1;\n return clamp01((vh - rect.top) / (vh + rect.height));\n }\n\n /** The shared pointer-field state + ripples for the renderer (live references — read synchronously). */\n sample(): InteractionSample {\n this.out.presence = this.presence;\n return this.out;\n }\n\n /** This wave's smoothed pointer-field state (it trails the cursor at its own hover smoothing), or\n * null if the wave hasn't been advanced by update() yet (treat as rest). */\n fieldFor(waveIdx: number): PointerField | null {\n return this.fields[waveIdx] ?? null;\n }\n\n /** Velocity-driven agitation drive 0..1 (how fast the cursor is moving, presence-gated). The\n * renderer scales each wave's hover `agitate` by this, so the churn tracks the gesture instead\n * of buzzing at a constant rate whenever the cursor is merely present. */\n pointerFlux(): number {\n return this.pointerSpeed;\n }\n\n /** Smoothed pointer velocity, NDC/s (direction + speed of the drag). The renderer feeds it to the\n * drag-wake shader so the trailing trough forms behind the motion. Live reference — read per frame. */\n pointerVelocity(): THREE.Vector2 {\n return this.velNdc;\n }\n\n /** Feed a `custom:<name>` input (developer API; staged/forwarded by the shell). */\n setInput(name: string, value: number): void {\n if (typeof name !== \"string\" || !Number.isFinite(value)) return;\n this.customInputs.set(name, value);\n }\n\n /**\n * Collapse to the settled resting state for the single frame drawn when the loop stops (paused /\n * reduced-motion / offscreen): presence / velocity / press / pointerSpeed → 0, ripples cleared,\n * scroll → its current raw value, pointer → centre, and `appear` → 1 (reduced-motion users must\n * see the FINAL entered state). Custom inputs KEEP their last explicit values. Each binding snaps\n * to its settled source so the one settled frame shows the final look.\n */\n settle(): void {\n this.presence = this.presenceTarget = 0;\n this.press = this.pressTarget = 0;\n this.pointerSpeed = 0;\n this.velNdc.set(0, 0);\n this.ndc.set(0, 0);\n this.ndcTarget.set(0, 0);\n this.ndcPrev.set(0, 0);\n for (const f of this.fields) {\n f.ndc.set(0, 0);\n f.vel.set(0, 0);\n f.presence = 0;\n }\n for (const r of this.ripples) {\n r.age = 0;\n r.amp = 0;\n r.active = false;\n }\n const rawScroll = this.scrollOverride ?? this.computeScroll();\n this.scroll = this.scrollPrev = rawScroll;\n this.scrollVel = 0;\n this.appearLatched = true;\n const cfg = this.cfg();\n if (cfg) {\n this.bindingState.clear();\n const snap = (b: AnyBinding): void => {\n this.bindingState.set(b, { value: this.rawSource(b.source), source: b.source });\n };\n for (const b of cfg.interaction?.bindings ?? []) snap(b);\n for (const w of cfg.waves) for (const b of w.interaction?.bindings ?? []) snap(b);\n }\n }\n\n /**\n * Snap scroll progress + the scroll-sourced bindings to the current override at once, leaving\n * every other input (pointer / press / appear / velocity / custom) advancing live. Used by the\n * studio scroll preview: the studio page never really scrolls, so dragging the preview slider is a\n * manual scrub that must reflect the instant you move it — not on the next animation frame, which\n * the browser fully suspends whenever the tab isn't foreground. Unlike settle() (which collapses\n * ALL input to rest for a paused still frame), this touches only the scroll signal.\n */\n snapScroll(): void {\n const raw = this.scrollOverride ?? this.computeScroll();\n this.scroll = this.scrollPrev = raw;\n this.scrollVel = 0; // a static scrub has no velocity\n const cfg = this.cfg();\n if (!cfg) return;\n const snap = (b: AnyBinding): void => {\n if (b.source === \"scroll\" || b.source === \"scrollVelocity\") {\n this.bindingState.set(b, { value: this.rawSource(b.source), source: b.source });\n }\n };\n for (const b of cfg.interaction?.bindings ?? []) snap(b);\n for (const w of cfg.waves) for (const b of w.interaction?.bindings ?? []) snap(b);\n }\n\n dispose(): void {\n const c = this.container;\n c.removeEventListener(\"pointerenter\", this.onPointerEnter);\n c.removeEventListener(\"pointermove\", this.onPointerMove);\n c.removeEventListener(\"pointerleave\", this.onPointerLeave);\n c.removeEventListener(\"pointercancel\", this.onPointerCancel);\n c.removeEventListener(\"pointerdown\", this.onPointerDown);\n c.removeEventListener(\"pointerup\", this.onPointerUp);\n this.customInputs.clear();\n this.bindingState.clear();\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"interaction.js","names":[],"sources":["../../src/renderer/interaction.ts"],"sourcesContent":["// The optional interactivity runtime for the wave renderer: a per-wave pointer field (localized\n// cursor effects) + per-wave and scene input→param bindings, driven by ONE shared cursor/scroll.\n// It lives in renderer/ so it stays below the shell/studio/index layers (depcruise); it may import\n// only `three`, ../config/model, and ../util/math.\n//\n// Split of responsibility with WaveRenderer: this controller owns ALL input + smoothing (the one\n// cursor's position / presence / press / velocity, scroll progress + velocity, the `appear` latch,\n// custom inputs, click ripples, and every binding's smoothed 0..1 source value — keyed by binding\n// identity so scene + per-wave binding lists all get their own smoothing). The renderer reads\n// sample() / bindingValue() once per frame and writes uniforms — the pointer field per wave, and\n// bindings via the WAVE_APPLIERS / SCENE_APPLIERS tables. Bindings NEVER mutate `config`.\nimport * as THREE from \"three\";\nimport { clamp01 } from \"../util/math\";\nimport { RIPPLE_SLOTS, tiltActive } from \"./interactionGates\";\nimport { type TiltStatus, TiltSource } from \"./tilt\";\nimport type {\n InteractionSource,\n SceneInteractionBinding,\n SceneInteractionTarget,\n StudioConfig,\n WaveConfig,\n WaveInteractionBinding,\n WaveInteractionTarget,\n} from \"../config/model\";\n\nconst RIPPLE_LIFETIME = 1.5; // seconds a ripple lives (crest travels out + fades by then)\nconst VELOCITY_TAU = 0.08; // pointer-velocity smoothing time constant (seconds)\nconst POINTER_SPEED_REF = 4.0; // NDC/s that normalizes pointerSpeed to 1.0\nconst SCROLL_VELOCITY_REF = 2.0; // progress/s that normalizes scrollVelocity to 1.0\nconst SCROLL_VELOCITY_TAU = 0.15; // scroll-velocity smoothing (seconds)\nconst DEFAULT_POINTER_TAU = 0.12; // pointer-follow smoothing default (seconds)\nconst DEFAULT_TILT_TAU = 0.18; // device-tilt smoothing default (seconds); noisier than a cursor\nconst DEFAULT_BINDING_TAU = 0.25; // per-binding source smoothing default (seconds)\nconst POINTER_SPRING_ZETA = 0.7; // pointer-field damping ratio (<1 → slight overshoot = \"weight\")\nconst MIN_POINTER_TAU = 0.02; // floor before smoothing→spring frequency (omega = 1/tau)\nconst SPRING_MAX_STEP = 1 / 120; // substep the spring below this dt so it stays stable after a stall\nconst SPRING_MAX_SUBSTEPS = 6;\n\ntype AnyBinding = WaveInteractionBinding | SceneInteractionBinding;\n\n/** Frame-rate-independent exponential smoothing factor for time constant `tau` (seconds). */\nfunction alpha(tau: number, dt: number): number {\n return tau > 0 ? 1 - Math.exp(-dt / tau) : 1;\n}\n\n/**\n * Advance a damped spring (`pos`/`vel`) toward `target` by `dt`, using semi-implicit (symplectic)\n * Euler. `omega` is the natural angular frequency (≈ 1/response-time), `zeta` the damping ratio\n * (<1 underdamped → overshoots and settles; 1 critical; >1 sluggish). Unlike a first-order lag this\n * carries momentum, so motion has weight and settles instead of creeping to a dead stop. Substeps\n * when `dt` spikes (e.g. the tab was backgrounded) so a stiff spring can't blow up; ~1 step at 60fps.\n */\nfunction springVec2(\n pos: THREE.Vector2,\n vel: THREE.Vector2,\n target: THREE.Vector2,\n omega: number,\n zeta: number,\n dt: number,\n): void {\n if (dt <= 0) return;\n const steps =\n dt > SPRING_MAX_STEP ? Math.min(Math.ceil(dt / SPRING_MAX_STEP), SPRING_MAX_SUBSTEPS) : 1;\n const h = dt / steps;\n const k = omega * omega;\n const c = 2 * zeta * omega;\n for (let s = 0; s < steps; s++) {\n vel.x += (k * (target.x - pos.x) - c * vel.x) * h;\n vel.y += (k * (target.y - pos.y) - c * vel.y) * h;\n pos.x += vel.x * h;\n pos.y += vel.y * h;\n }\n}\n\n// ---- Binding applier tables -----------------------------------------------------------------\n\n/** What a wave-scoped applier writes into: one wave's uniforms + its mesh transform. */\nexport interface WaveApplyArgs {\n u: Record<string, THREE.IUniform>;\n mesh: THREE.Object3D;\n}\n/** What a scene-scoped applier writes into: the post-pass uniforms + a small out-param the renderer\n * seeds (0 / 1) each frame and reads back (the interaction time-offset + zoom multiplier). */\nexport interface SceneApplyArgs {\n post: Record<string, THREE.IUniform>;\n out: { timeOffset: number; zoom: number };\n}\ninterface WaveApplier {\n base(w: WaveConfig): number;\n apply(value: number, a: WaveApplyArgs): void;\n}\ninterface SceneApplier {\n base(c: StudioConfig): number;\n apply(value: number, a: SceneApplyArgs): void;\n}\n\nconst waveApplier = (\n base: (w: WaveConfig) => number,\n apply: (value: number, a: WaveApplyArgs) => void,\n): WaveApplier => ({ base, apply });\nconst sceneApplier = (\n base: (c: StudioConfig) => number,\n apply: (value: number, a: SceneApplyArgs) => void,\n): SceneApplier => ({ base, apply });\n\n/**\n * Per-wave binding targets → (how to read the authored base value, how to write the modulated one).\n * Each base() mirrors the exact fallback refresh() uses, so a binding at rest (from omitted, source\n * 0) writes the same value the renderer already had — no visible jump. This object is the runtime\n * source of truth for {@link WaveInteractionTarget} (enforced by `satisfies`).\n */\nexport const WAVE_APPLIERS = {\n displaceAmount: waveApplier(\n (w) => w.displaceAmount,\n (v, a) => {\n a.u.uDispAmount.value = v;\n },\n ),\n detailAmount: waveApplier(\n (w) => w.detailAmount ?? 0,\n (v, a) => {\n a.u.uDetailAmount.value = v;\n },\n ),\n twistPowerX: waveApplier(\n (w) => w.twistPower.x,\n (v, a) => {\n a.u.uTwPowX.value = v;\n },\n ),\n twistPowerY: waveApplier(\n (w) => w.twistPower.y,\n (v, a) => {\n a.u.uTwPowY.value = v;\n },\n ),\n twistPowerZ: waveApplier(\n (w) => w.twistPower.z,\n (v, a) => {\n a.u.uTwPowZ.value = v;\n },\n ),\n twistFrequencyX: waveApplier(\n (w) => w.twistFrequency.x,\n (v, a) => {\n a.u.uTwFreqX.value = v;\n },\n ),\n twistFrequencyY: waveApplier(\n (w) => w.twistFrequency.y,\n (v, a) => {\n a.u.uTwFreqY.value = v;\n },\n ),\n twistFrequencyZ: waveApplier(\n (w) => w.twistFrequency.z,\n (v, a) => {\n a.u.uTwFreqZ.value = v;\n },\n ),\n hueShift: waveApplier(\n (w) => w.hueShift,\n (v, a) => {\n a.u.uHueShift.value = v;\n },\n ),\n gradientShift: waveApplier(\n (w) => w.gradientShift ?? 0,\n (v, a) => {\n a.u.uGradShift.value = v;\n },\n ),\n colorSaturation: waveApplier(\n (w) => w.colorSaturation,\n (v, a) => {\n a.u.uSaturation.value = v;\n },\n ),\n opacity: waveApplier(\n (w) => w.opacity,\n (v, a) => {\n a.u.uOpacity.value = v;\n },\n ),\n lineThickness: waveApplier(\n (w) => w.lineThickness ?? 1,\n (v, a) => {\n a.u.uLineThickness.value = v;\n },\n ),\n lineAmount: waveApplier(\n (w) => w.lineAmount ?? 425,\n (v, a) => {\n a.u.uLineAmount.value = v;\n },\n ),\n fiberStrength: waveApplier(\n (w) => w.fiberStrength,\n (v, a) => {\n a.u.uFiberStrength.value = v;\n },\n ),\n sheen: waveApplier(\n (w) => w.sheen ?? 1,\n (v, a) => {\n a.u.uSheen.value = v;\n },\n ),\n iridescence: waveApplier(\n (w) => w.iridescence ?? 0,\n (v, a) => {\n a.u.uIridescence.value = v;\n },\n ),\n // Helix: phase spins the coil, turns winds/unwinds it, radius opens and closes it. All three are\n // inert unless the wave already has a helix, so waveDefines compiles HELIX for a wave that binds\n // one but authors radius/roll at 0 (same reason detailAmount does — see bindsDetail there).\n helixPhase: waveApplier(\n (w) => w.helixPhase ?? 0,\n (v, a) => {\n a.u.uHelixPhase.value = v;\n },\n ),\n helixTurns: waveApplier(\n (w) => w.helixTurns ?? 0,\n (v, a) => {\n a.u.uHelixTurns.value = v;\n },\n ),\n helixRadius: waveApplier(\n (w) => w.helixRadius ?? 0,\n (v, a) => {\n a.u.uHelixRadius.value = v;\n },\n ),\n positionX: waveApplier(\n (w) => w.position.x,\n (v, a) => {\n a.mesh.position.x = v;\n },\n ),\n positionY: waveApplier(\n (w) => w.position.y,\n (v, a) => {\n a.mesh.position.y = v;\n },\n ),\n} satisfies Record<WaveInteractionTarget, WaveApplier>;\n\n/** Scene-level binding targets. base() mirrors updateTime() / applyZoom() / applyPost() fallbacks. */\nexport const SCENE_APPLIERS = {\n timeOffset: sceneApplier(\n (c) => c.timeOffset ?? 0,\n (v, a) => {\n a.out.timeOffset = v;\n },\n ),\n cameraZoom: sceneApplier(\n (c) => c.cameraZoom ?? 1,\n (v, a) => {\n a.out.zoom = v;\n },\n ),\n blur: sceneApplier(\n (c) => c.blur,\n (v, a) => {\n a.post.uBlurAmount.value = v;\n },\n ),\n grain: sceneApplier(\n (c) => c.grain,\n (v, a) => {\n a.post.uGrainAmount.value = v;\n },\n ),\n} satisfies Record<SceneInteractionTarget, SceneApplier>;\n\n// ---- Active-state predicates (keyed off config only, so input never triggers a recompile) ----\n\n// ---- Sample shape + the controller ----------------------------------------------------------\n\ninterface RippleSlot {\n origin: THREE.Vector2; // NDC\n age: number; // seconds since spawn\n amp: number; // 0..1 decay envelope (0 = free slot)\n}\ninterface RippleState extends RippleSlot {\n active: boolean;\n}\n\n/** A per-frame snapshot of the pointer-field state. Fields are LIVE references into the controller's\n * state — read them synchronously each frame; don't retain them. */\nexport interface InteractionSample {\n /** Smoothed pointer position, NDC (-1..1). */\n ndc: THREE.Vector2;\n /** Smoothed pointer presence 0..1 (→ uPointerActive). */\n presence: number;\n /** Click-ripple ring buffer (amp = shared 0..1 envelope; 0 = free slot). */\n ripples: readonly RippleSlot[];\n}\n\n/** A wave's own smoothed pointer-field state — trails the shared cursor at the wave's own rate. */\nexport interface PointerField {\n /** Smoothed pointer position for this wave, NDC (-1..1). */\n ndc: THREE.Vector2;\n /** Spring velocity of `ndc` (NDC/s) — internal spring state, not read by the renderer. */\n vel: THREE.Vector2;\n /** Smoothed pointer presence 0..1 for this wave. */\n presence: number;\n}\n\n/**\n * Owns the one cursor's input + scroll + press/appear/custom and all smoothing. Constructed by the\n * renderer when {@link interactionActive} first turns true, disposed when it turns false. All\n * listeners are passive and container-scoped (the poster overlay passes events through).\n */\nexport class InteractionController {\n /** Studio-only scroll preview: when non-null, overrides the computed scroll progress. */\n scrollOverride: number | null = null;\n\n private readonly ndc = new THREE.Vector2();\n private readonly ndcTarget = new THREE.Vector2();\n private readonly ndcPrev = new THREE.Vector2();\n private readonly velNdc = new THREE.Vector2();\n private presence = 0;\n private presenceTarget = 0;\n /** Whether a REAL pointer is on the element. Tracked apart from `presenceTarget` because tilt can\n * raise presence too (tilt.pointer), and it must yield the moment a finger or cursor shows up. */\n private pointerPresent = false;\n private press = 0;\n private pressTarget = 0;\n private pointerSpeed = 0;\n private scroll = 0;\n private scrollPrev = 0;\n private scrollVel = 0;\n private appearLatched = false;\n /** The orientation sensor, built only for a scene that declares `interaction.tilt`. */\n private tilt: TiltSource | null = null;\n private tiltX = 0.5; // smoothed 0..1 (0.5 = neutral pose), mirroring the pointer's own smoothing\n private tiltY = 0.5;\n private readonly customInputs = new Map<string, number>();\n private readonly ripples: RippleState[] = [];\n // Per-wave pointer-field state (index-parallel to config.waves); each trails the cursor at its own\n // hover smoothing. Grown/shrunk in update().\n private readonly fields: PointerField[] = [];\n // Per-binding smoothing state, keyed by binding-object identity (covers scene + every wave list).\n private readonly bindingState = new Map<\n AnyBinding,\n { value: number; source: InteractionSource }\n >();\n // Scratch set reused by updateBindings every frame (cleared, never reallocated).\n private readonly seenBindings = new Set<AnyBinding>();\n private readonly out: InteractionSample;\n\n constructor(\n private readonly container: HTMLElement,\n private readonly cfg: () => StudioConfig | undefined,\n ) {\n for (let i = 0; i < RIPPLE_SLOTS; i++) {\n this.ripples.push({ origin: new THREE.Vector2(), age: 0, amp: 0, active: false });\n }\n this.out = { ndc: this.ndc, presence: 0, ripples: this.ripples };\n const opts = { passive: true } as const;\n container.addEventListener(\"pointerenter\", this.onPointerEnter, opts);\n container.addEventListener(\"pointermove\", this.onPointerMove, opts);\n container.addEventListener(\"pointerleave\", this.onPointerLeave, opts);\n container.addEventListener(\"pointercancel\", this.onPointerCancel, opts);\n container.addEventListener(\"pointerdown\", this.onPointerDown, opts);\n container.addEventListener(\"pointerup\", this.onPointerUp, opts);\n }\n\n /** Ignore coarse (touch) pointers unless the scene opts in with interaction.touch. */\n private ignore(e: PointerEvent): boolean {\n return e.pointerType === \"touch\" && this.cfg()?.interaction?.touch !== true;\n }\n\n private setNdcTarget(e: PointerEvent): void {\n const rect = this.container.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n this.ndcTarget.set(\n ((e.clientX - rect.left) / rect.width) * 2 - 1,\n -(((e.clientY - rect.top) / rect.height) * 2 - 1),\n );\n }\n\n private onPointerEnter = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n this.pointerPresent = true;\n this.presenceTarget = 1;\n this.setNdcTarget(e);\n };\n private onPointerMove = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n if (e.pointerType === \"touch\" && this.pressTarget < 0.5) return; // touch: only track while down\n this.pointerPresent = true;\n this.presenceTarget = 1;\n this.setNdcTarget(e);\n };\n private onPointerLeave = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n this.pointerPresent = false;\n this.presenceTarget = 0;\n this.ndcTarget.set(0, 0); // relax toward centre → pointerX/Y rest at 0.5\n };\n private onPointerCancel = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n this.pointerPresent = false;\n this.pressTarget = 0;\n this.presenceTarget = 0;\n this.ndcTarget.set(0, 0);\n };\n private onPointerDown = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n this.pointerPresent = true;\n this.pressTarget = 1;\n this.presenceTarget = 1;\n this.setNdcTarget(e);\n // Spawn a ripple only if some wave actually wants ripples (else it is wasted state).\n const cfg = this.cfg();\n if (cfg && cfg.waves.some((w) => (w.interaction?.press?.ripple ?? 0) > 0)) this.spawnRipple();\n };\n private onPointerUp = (e: PointerEvent): void => {\n if (this.ignore(e)) return;\n this.pressTarget = 0;\n if (e.pointerType === \"touch\") {\n this.pointerPresent = false;\n this.presenceTarget = 0; // touch has no hover — presence ends with the touch\n this.ndcTarget.set(0, 0);\n }\n };\n\n /** Spawn a normalized ripple (envelope 0..1) at the click NDC; per-wave amplitude scales it in the\n * shader. Reuses a free slot or evicts the oldest. */\n private spawnRipple(): void {\n let slot = this.ripples.find((r) => !r.active);\n if (!slot) {\n slot = this.ripples[0];\n for (const r of this.ripples) if (r.age > slot.age) slot = r;\n }\n slot.origin.copy(this.ndcTarget);\n slot.age = 0;\n slot.amp = 1;\n slot.active = true;\n }\n\n /** Advance all smoothed state by `dt` seconds. Called from the render loop with the same delta. */\n update(dt: number): void {\n const cfg = this.cfg();\n if (!cfg) return;\n const d = Math.max(dt, 0);\n // The SHARED pointer state feeds binding sources (hover / pointerX-Y / pointerSpeed / press) at a\n // fixed baseline; each wave's FIELD trails at its own hover smoothing further below.\n const kPointer = alpha(DEFAULT_POINTER_TAU, d);\n\n // Device tilt, BEFORE the pointer block: with `tilt.pointer` on it writes the same ndcTarget the\n // cursor would, and everything downstream (the shared smoothing, each wave's spring) must see it\n // in the frame it changed rather than one frame late.\n this.updateTilt(cfg, d);\n\n // Pointer position + presence + press.\n this.ndcPrev.copy(this.ndc);\n this.ndc.lerp(this.ndcTarget, kPointer);\n this.presence += (this.presenceTarget - this.presence) * kPointer;\n this.press += (this.pressTarget - this.press) * kPointer;\n\n // Velocity (own tau) from the smoothed-position delta.\n if (d > 1e-5) {\n const kv = alpha(VELOCITY_TAU, d);\n this.velNdc.x += ((this.ndc.x - this.ndcPrev.x) / d - this.velNdc.x) * kv;\n this.velNdc.y += ((this.ndc.y - this.ndcPrev.y) / d - this.velNdc.y) * kv;\n }\n this.pointerSpeed = this.presence * clamp01(this.velNdc.length() / POINTER_SPEED_REF);\n\n // Per-wave pointer FIELD: each wave's position is a damped SPRING toward the raw cursor target,\n // so a stack trails the cursor at different rates (parallax) and — because the spring is slightly\n // underdamped — carries a little weight: it overshoots and settles instead of creeping to a dead\n // stop. Presence stays a plain ramp (a spring there could dip below 0 and invert the effect).\n // A wave's hover `smoothing` sets the spring frequency (omega = 1/tau).\n const waves = cfg.waves;\n if (this.fields.length > waves.length) this.fields.length = waves.length;\n for (let i = 0; i < waves.length; i++) {\n let f = this.fields[i];\n if (!f) {\n f = {\n ndc: this.ndcTarget.clone(),\n vel: new THREE.Vector2(),\n presence: this.presenceTarget,\n };\n this.fields[i] = f;\n }\n const tau = Math.max(\n waves[i].interaction?.hover?.smoothing ?? DEFAULT_POINTER_TAU,\n MIN_POINTER_TAU,\n );\n springVec2(f.ndc, f.vel, this.ndcTarget, 1 / tau, POINTER_SPRING_ZETA, d);\n f.presence += (this.presenceTarget - f.presence) * alpha(tau, d);\n }\n\n // Scroll progress + velocity.\n const rawScroll = this.scrollOverride ?? this.computeScroll();\n if (d > 1e-5) {\n const sv = Math.abs(rawScroll - this.scrollPrev) / d;\n this.scrollVel += (sv - this.scrollVel) * alpha(SCROLL_VELOCITY_TAU, d);\n }\n this.scrollPrev = rawScroll;\n this.scroll = rawScroll;\n\n // Appear latch: the render loop is visibility-gated, so the first update() IS first-visible.\n this.appearLatched = true;\n\n // Ripples: age + quadratic-decay envelope.\n for (const r of this.ripples) {\n if (!r.active) continue;\n r.age += d;\n const env = Math.max(0, 1 - r.age / RIPPLE_LIFETIME);\n r.amp = env * env;\n if (r.amp <= 0) r.active = false;\n }\n\n this.updateBindings(cfg, d);\n }\n\n /**\n * Build or drop the tilt sensor as the config declares it, then advance the smoothed axes. The\n * sensor is created lazily and only for a scene that asked for it, so a cursor-only scene attaches\n * no orientation listener at all — and a studio edit that adds or removes the block is picked up\n * on the next frame, the same way the controller itself is.\n */\n private updateTilt(cfg: StudioConfig, dt: number): void {\n if (!tiltActive(cfg)) {\n if (this.tilt) {\n this.tilt.dispose();\n this.tilt = null;\n this.tiltX = this.tiltY = 0.5;\n }\n return;\n }\n const src = this.ensureTilt();\n if (!src) return;\n const tiltCfg = cfg.interaction?.tilt;\n const k = alpha(Math.max(tiltCfg?.smoothing ?? DEFAULT_TILT_TAU, 0), dt);\n this.tiltX += (src.x - this.tiltX) * k;\n this.tiltY += (src.y - this.tiltY) * k;\n\n // Opt-in stand-in for the cursor: a phone has no pointer, so a scene whose bindings and hover\n // fields all read the cursor would sit at dead centre forever. Only while no real pointer is on\n // the element — a finger beats the sensor the instant it lands. NDC y is +1 at the TOP, and\n // tiltY counts DOWN the page (the way a ball rolls), hence the negation.\n if (tiltCfg?.pointer && src.live && !this.pointerPresent) {\n this.ndcTarget.set(this.tiltX * 2 - 1, -(this.tiltY * 2 - 1));\n this.presenceTarget = 1;\n }\n }\n\n /** The sensor, built on first use. Null when the platform has none (every desktop browser). */\n private ensureTilt(): TiltSource | null {\n if (!this.tilt && TiltSource.supported()) {\n this.tilt = new TiltSource(() => this.cfg()?.interaction?.tilt);\n }\n return this.tilt;\n }\n\n /**\n * Ask for the orientation sensor, which on iOS 13+ MUST happen inside a user gesture (a tap\n * handler — not a `setTimeout` or a promise chain that outlives the gesture). Resolves true once\n * readings can flow. False means the platform has no sensor, the scene declares no `tilt` block,\n * or the reader refused. Everywhere that needs no permission, tilt is already live and this\n * simply resolves true.\n */\n async enableTilt(): Promise<boolean> {\n const cfg = this.cfg();\n if (!cfg || !tiltActive(cfg)) return false;\n return (await this.ensureTilt()?.enable()) ?? false;\n }\n\n /** Where the sensor stands — `\"prompt\"` is exactly when a tap-to-enable affordance would help. */\n tiltStatus(): TiltStatus {\n if (!TiltSource.supported()) return \"unsupported\";\n return this.tilt?.status ?? \"prompt\";\n }\n\n /** Take the next reading as the neutral pose (the reader has changed grip). */\n recenterTilt(): void {\n this.tilt?.recenter();\n }\n\n // Indexed loops + a reused scratch set (no per-frame closure/array/Set) — this runs every frame.\n private updateBindings(cfg: StudioConfig, dt: number): void {\n const seen = this.seenBindings;\n seen.clear();\n const sceneBindings = cfg.interaction?.bindings;\n if (sceneBindings) {\n for (let i = 0; i < sceneBindings.length; i++) this.advanceBinding(sceneBindings[i], dt);\n }\n for (let w = 0; w < cfg.waves.length; w++) {\n const bindings = cfg.waves[w].interaction?.bindings;\n if (!bindings) continue;\n for (let i = 0; i < bindings.length; i++) this.advanceBinding(bindings[i], dt);\n }\n // Prune state for bindings that no longer exist (edited/removed slots). advanceBinding puts every\n // seen binding in the map, so map ⊇ seen — equal sizes means nothing is stale to walk for.\n if (this.bindingState.size > seen.size) {\n for (const key of this.bindingState.keys()) if (!seen.has(key)) this.bindingState.delete(key);\n }\n }\n\n /** Advance one binding's smoothed source value by `dt` and mark it live in `seenBindings`. */\n private advanceBinding(b: AnyBinding, dt: number): void {\n this.seenBindings.add(b);\n const raw = this.rawSource(b.source);\n let st = this.bindingState.get(b);\n // (Re)initialise on first sight or when the slot's source changed (studio edit): `appear`\n // ramps from 0 (entrance), every other source snaps to its current value.\n if (!st || st.source !== b.source) {\n st = { value: b.source === \"appear\" ? 0 : raw, source: b.source };\n this.bindingState.set(b, st);\n }\n st.value += (raw - st.value) * alpha(b.smoothing ?? DEFAULT_BINDING_TAU, dt);\n }\n\n /** The current smoothed 0..1 value of a binding's source (0 if the binding is unknown). */\n bindingValue(b: AnyBinding): number {\n return this.bindingState.get(b)?.value ?? 0;\n }\n\n /** The current raw (un-per-binding-smoothed) 0..1 value of a source signal. */\n private rawSource(source: InteractionSource): number {\n switch (source) {\n case \"scroll\":\n return this.scroll;\n case \"hover\":\n return this.presence;\n case \"pointerX\":\n return (this.ndc.x + 1) * 0.5;\n case \"pointerY\":\n return (this.ndc.y + 1) * 0.5;\n case \"pointerSpeed\":\n return this.pointerSpeed;\n case \"press\":\n return this.press;\n case \"scrollVelocity\":\n return clamp01(this.scrollVel / SCROLL_VELOCITY_REF);\n case \"appear\":\n return this.appearLatched ? 1 : 0;\n case \"tiltX\":\n return this.tiltX;\n case \"tiltY\":\n return this.tiltY;\n default:\n // custom:<name> — fed by setInput(name, value).\n return this.customInputs.get(source.slice(\"custom:\".length)) ?? 0;\n }\n }\n\n /** Container progress through the viewport: 0 as it enters from below, 1 once scrolled past. */\n private computeScroll(): number {\n const rect = this.container.getBoundingClientRect();\n const vh = window.innerHeight || document.documentElement.clientHeight || 1;\n return clamp01((vh - rect.top) / (vh + rect.height));\n }\n\n /** The shared pointer-field state + ripples for the renderer (live references — read synchronously). */\n sample(): InteractionSample {\n this.out.presence = this.presence;\n return this.out;\n }\n\n /** This wave's smoothed pointer-field state (it trails the cursor at its own hover smoothing), or\n * null if the wave hasn't been advanced by update() yet (treat as rest). */\n fieldFor(waveIdx: number): PointerField | null {\n return this.fields[waveIdx] ?? null;\n }\n\n /** Velocity-driven agitation drive 0..1 (how fast the cursor is moving, presence-gated). The\n * renderer scales each wave's hover `agitate` by this, so the churn tracks the gesture instead\n * of buzzing at a constant rate whenever the cursor is merely present. */\n pointerFlux(): number {\n return this.pointerSpeed;\n }\n\n /** Smoothed pointer velocity, NDC/s (direction + speed of the drag). The renderer feeds it to the\n * drag-wake shader so the trailing trough forms behind the motion. Live reference — read per frame. */\n pointerVelocity(): THREE.Vector2 {\n return this.velNdc;\n }\n\n /** Feed a `custom:<name>` input (developer API; staged/forwarded by the shell). */\n setInput(name: string, value: number): void {\n if (typeof name !== \"string\" || !Number.isFinite(value)) return;\n this.customInputs.set(name, value);\n }\n\n /**\n * Collapse to the settled resting state for the single frame drawn when the loop stops (paused /\n * reduced-motion / offscreen): presence / velocity / press / pointerSpeed → 0, ripples cleared,\n * scroll → its current raw value, pointer → centre, and `appear` → 1 (reduced-motion users must\n * see the FINAL entered state). Custom inputs KEEP their last explicit values. Each binding snaps\n * to its settled source so the one settled frame shows the final look.\n */\n settle(): void {\n this.presence = this.presenceTarget = 0;\n this.pointerPresent = false;\n this.press = this.pressTarget = 0;\n this.pointerSpeed = 0;\n this.tiltX = this.tiltY = 0.5; // a settled frame is the neutral pose, like the centred cursor\n this.velNdc.set(0, 0);\n this.ndc.set(0, 0);\n this.ndcTarget.set(0, 0);\n this.ndcPrev.set(0, 0);\n for (const f of this.fields) {\n f.ndc.set(0, 0);\n f.vel.set(0, 0);\n f.presence = 0;\n }\n for (const r of this.ripples) {\n r.age = 0;\n r.amp = 0;\n r.active = false;\n }\n const rawScroll = this.scrollOverride ?? this.computeScroll();\n this.scroll = this.scrollPrev = rawScroll;\n this.scrollVel = 0;\n this.appearLatched = true;\n const cfg = this.cfg();\n if (cfg) {\n this.bindingState.clear();\n const snap = (b: AnyBinding): void => {\n this.bindingState.set(b, { value: this.rawSource(b.source), source: b.source });\n };\n for (const b of cfg.interaction?.bindings ?? []) snap(b);\n for (const w of cfg.waves) for (const b of w.interaction?.bindings ?? []) snap(b);\n }\n }\n\n /**\n * Snap scroll progress + the scroll-sourced bindings to the current override at once, leaving\n * every other input (pointer / press / appear / velocity / custom) advancing live. Used by the\n * studio scroll preview: the studio page never really scrolls, so dragging the preview slider is a\n * manual scrub that must reflect the instant you move it — not on the next animation frame, which\n * the browser fully suspends whenever the tab isn't foreground. Unlike settle() (which collapses\n * ALL input to rest for a paused still frame), this touches only the scroll signal.\n */\n snapScroll(): void {\n const raw = this.scrollOverride ?? this.computeScroll();\n this.scroll = this.scrollPrev = raw;\n this.scrollVel = 0; // a static scrub has no velocity\n const cfg = this.cfg();\n if (!cfg) return;\n const snap = (b: AnyBinding): void => {\n if (b.source === \"scroll\" || b.source === \"scrollVelocity\") {\n this.bindingState.set(b, { value: this.rawSource(b.source), source: b.source });\n }\n };\n for (const b of cfg.interaction?.bindings ?? []) snap(b);\n for (const w of cfg.waves) for (const b of w.interaction?.bindings ?? []) snap(b);\n }\n\n dispose(): void {\n const c = this.container;\n c.removeEventListener(\"pointerenter\", this.onPointerEnter);\n c.removeEventListener(\"pointermove\", this.onPointerMove);\n c.removeEventListener(\"pointerleave\", this.onPointerLeave);\n c.removeEventListener(\"pointercancel\", this.onPointerCancel);\n c.removeEventListener(\"pointerdown\", this.onPointerDown);\n c.removeEventListener(\"pointerup\", this.onPointerUp);\n this.tilt?.dispose();\n this.tilt = null;\n this.customInputs.clear();\n this.bindingState.clear();\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,59 @@
1
+ //#region src/renderer/interactionGates.ts
2
+ /** The global master switch: only `scene.interaction.enabled === false` turns the whole layer off. */
3
+ function notDisabled(cfg) {
4
+ return cfg.interaction?.enabled !== false;
5
+ }
6
+ /** Whether a wave has a pointer field (hover effects, or a click ripple). */
7
+ function waveHasPointerField(w) {
8
+ const it = w.interaction;
9
+ return !!it && (!!it.hover || (it.press?.ripple ?? 0) > 0);
10
+ }
11
+ /** Whether this wave has an active pointer field → its POINTER_FX shader path compiles. */
12
+ function wavePointerFxActive(cfg, w) {
13
+ return notDisabled(cfg) && waveHasPointerField(w);
14
+ }
15
+ /** Whether this wave has active click ripples → its nested POINTER_RIPPLES path compiles. */
16
+ function waveRipplesActive(cfg, w) {
17
+ return notDisabled(cfg) && (w.interaction?.press?.ripple ?? 0) > 0;
18
+ }
19
+ /** Whether ANY wave has a pointer field (so the renderer bothers writing the shared pointer uniforms). */
20
+ function anyPointerFxActive(cfg) {
21
+ return notDisabled(cfg) && cfg.waves.some(waveHasPointerField);
22
+ }
23
+ /** Whether the interaction layer should run at all (any wave interaction, or any scene binding).
24
+ * This is the predicate that decides whether the runtime chunk is ever fetched. */
25
+ function interactionActive(cfg) {
26
+ if (!notDisabled(cfg)) return false;
27
+ if ((cfg.interaction?.bindings?.length ?? 0) > 0) return true;
28
+ return cfg.waves.some((w) => {
29
+ const it = w.interaction;
30
+ return !!it && (!!it.hover || (it.press?.ripple ?? 0) > 0 || (it.bindings?.length ?? 0) > 0);
31
+ });
32
+ }
33
+ /** True when any binding in the list reads the orientation sensor. */
34
+ function anyTiltSource(bindings) {
35
+ if (!bindings) return false;
36
+ for (let i = 0; i < bindings.length; i++) {
37
+ const s = bindings[i].source;
38
+ if (s === "tiltX" || s === "tiltY") return true;
39
+ }
40
+ return false;
41
+ }
42
+ /**
43
+ * Whether anything in this scene reads the orientation sensor: a `tiltX` / `tiltY` binding anywhere,
44
+ * or the opt-in that lets tilt stand in for the cursor. A tilt BINDING is the switch — the
45
+ * `interaction.tilt` block is tuning, exactly as `pointerX` needs no "pointer" block — so a scene
46
+ * that never mentions tilt attaches no `deviceorientation` listener and touches no sensor. Indexed
47
+ * loops and no closures: unlike {@link interactionActive} this one is checked every frame.
48
+ */
49
+ function tiltActive(cfg) {
50
+ if (!notDisabled(cfg)) return false;
51
+ if (cfg.interaction?.tilt?.pointer) return true;
52
+ if (anyTiltSource(cfg.interaction?.bindings)) return true;
53
+ for (let w = 0; w < cfg.waves.length; w++) if (anyTiltSource(cfg.waves[w].interaction?.bindings)) return true;
54
+ return false;
55
+ }
56
+ //#endregion
57
+ export { anyPointerFxActive, interactionActive, tiltActive, wavePointerFxActive, waveRipplesActive };
58
+
59
+ //# sourceMappingURL=interactionGates.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"interactionGates.js","names":[],"sources":["../../src/renderer/interactionGates.ts"],"sourcesContent":["// The synchronous half of the interactivity layer: pure config predicates plus the one constant the\n// shader is built against. Nothing here touches the DOM, holds state, or imports three.\n//\n// It is a SEPARATE MODULE for one reason — the tree-shaking boundary. The renderer needs these\n// answers synchronously (they decide which shader defines compile and whether the layer runs at\n// all), but the runtime behind them — the controller, its listeners, the applier tables, the tilt\n// sensor — is ~3.8 KB gzipped that a scene with no `interaction` block never executes. Keeping the\n// two in one file forced a static import of the whole layer into every bundle. Split, the renderer\n// imports only this (a few hundred bytes) and reaches interaction.ts through a dynamic import, so\n// the runtime is a chunk that is fetched only by pages that actually interact.\n//\n// The rule this file exists to enforce: NOTHING in the eager import graph may import\n// `./interaction` — reach it through `import(\"./interaction\")` instead.\nimport type { StudioConfig, WaveConfig } from \"../config/model\";\nimport type { SceneInteractionBinding, WaveInteractionBinding } from \"../config/model\";\n\n/** Click-ripple ring-buffer size. MUST match the `[4]` array sizes in shaders.ts (POINTER_RIPPLES). */\nexport const RIPPLE_SLOTS = 4;\n\ntype AnyBinding = WaveInteractionBinding | SceneInteractionBinding;\n\n/** The global master switch: only `scene.interaction.enabled === false` turns the whole layer off. */\nfunction notDisabled(cfg: StudioConfig): boolean {\n return cfg.interaction?.enabled !== false;\n}\n\n/** Whether a wave has a pointer field (hover effects, or a click ripple). */\nfunction waveHasPointerField(w: WaveConfig): boolean {\n const it = w.interaction;\n return !!it && (!!it.hover || (it.press?.ripple ?? 0) > 0);\n}\n\n/** Whether this wave has an active pointer field → its POINTER_FX shader path compiles. */\nexport function wavePointerFxActive(cfg: StudioConfig, w: WaveConfig): boolean {\n return notDisabled(cfg) && waveHasPointerField(w);\n}\n\n/** Whether this wave has active click ripples → its nested POINTER_RIPPLES path compiles. */\nexport function waveRipplesActive(cfg: StudioConfig, w: WaveConfig): boolean {\n return notDisabled(cfg) && (w.interaction?.press?.ripple ?? 0) > 0;\n}\n\n/** Whether ANY wave has a pointer field (so the renderer bothers writing the shared pointer uniforms). */\nexport function anyPointerFxActive(cfg: StudioConfig): boolean {\n return notDisabled(cfg) && cfg.waves.some(waveHasPointerField);\n}\n\n/** Whether the interaction layer should run at all (any wave interaction, or any scene binding).\n * This is the predicate that decides whether the runtime chunk is ever fetched. */\nexport function interactionActive(cfg: StudioConfig): boolean {\n if (!notDisabled(cfg)) return false;\n if ((cfg.interaction?.bindings?.length ?? 0) > 0) return true;\n return cfg.waves.some((w) => {\n const it = w.interaction;\n return !!it && (!!it.hover || (it.press?.ripple ?? 0) > 0 || (it.bindings?.length ?? 0) > 0);\n });\n}\n\n/** True when any binding in the list reads the orientation sensor. */\nfunction anyTiltSource(bindings: readonly AnyBinding[] | undefined): boolean {\n if (!bindings) return false;\n for (let i = 0; i < bindings.length; i++) {\n const s = bindings[i].source;\n if (s === \"tiltX\" || s === \"tiltY\") return true;\n }\n return false;\n}\n\n/**\n * Whether anything in this scene reads the orientation sensor: a `tiltX` / `tiltY` binding anywhere,\n * or the opt-in that lets tilt stand in for the cursor. A tilt BINDING is the switch — the\n * `interaction.tilt` block is tuning, exactly as `pointerX` needs no \"pointer\" block — so a scene\n * that never mentions tilt attaches no `deviceorientation` listener and touches no sensor. Indexed\n * loops and no closures: unlike {@link interactionActive} this one is checked every frame.\n */\nexport function tiltActive(cfg: StudioConfig): boolean {\n if (!notDisabled(cfg)) return false;\n if (cfg.interaction?.tilt?.pointer) return true;\n if (anyTiltSource(cfg.interaction?.bindings)) return true;\n for (let w = 0; w < cfg.waves.length; w++) {\n if (anyTiltSource(cfg.waves[w].interaction?.bindings)) return true;\n }\n return false;\n}\n"],"mappings":";;AAsBA,SAAS,YAAY,KAA4B;CAC/C,OAAO,IAAI,aAAa,YAAY;AACtC;;AAGA,SAAS,oBAAoB,GAAwB;CACnD,MAAM,KAAK,EAAE;CACb,OAAO,CAAC,CAAC,OAAO,CAAC,CAAC,GAAG,UAAU,GAAG,OAAO,UAAU,KAAK;AAC1D;;AAGA,SAAgB,oBAAoB,KAAmB,GAAwB;CAC7E,OAAO,YAAY,GAAG,KAAK,oBAAoB,CAAC;AAClD;;AAGA,SAAgB,kBAAkB,KAAmB,GAAwB;CAC3E,OAAO,YAAY,GAAG,MAAM,EAAE,aAAa,OAAO,UAAU,KAAK;AACnE;;AAGA,SAAgB,mBAAmB,KAA4B;CAC7D,OAAO,YAAY,GAAG,KAAK,IAAI,MAAM,KAAK,mBAAmB;AAC/D;;;AAIA,SAAgB,kBAAkB,KAA4B;CAC5D,IAAI,CAAC,YAAY,GAAG,GAAG,OAAO;CAC9B,KAAK,IAAI,aAAa,UAAU,UAAU,KAAK,GAAG,OAAO;CACzD,OAAO,IAAI,MAAM,MAAM,MAAM;EAC3B,MAAM,KAAK,EAAE;EACb,OAAO,CAAC,CAAC,OAAO,CAAC,CAAC,GAAG,UAAU,GAAG,OAAO,UAAU,KAAK,MAAM,GAAG,UAAU,UAAU,KAAK;CAC5F,CAAC;AACH;;AAGA,SAAS,cAAc,UAAsD;CAC3E,IAAI,CAAC,UAAU,OAAO;CACtB,KAAK,IAAI,IAAI,GAAG,IAAI,SAAS,QAAQ,KAAK;EACxC,MAAM,IAAI,SAAS,EAAE,CAAC;EACtB,IAAI,MAAM,WAAW,MAAM,SAAS,OAAO;CAC7C;CACA,OAAO;AACT;;;;;;;;AASA,SAAgB,WAAW,KAA4B;CACrD,IAAI,CAAC,YAAY,GAAG,GAAG,OAAO;CAC9B,IAAI,IAAI,aAAa,MAAM,SAAS,OAAO;CAC3C,IAAI,cAAc,IAAI,aAAa,QAAQ,GAAG,OAAO;CACrD,KAAK,IAAI,IAAI,GAAG,IAAI,IAAI,MAAM,QAAQ,KACpC,IAAI,cAAc,IAAI,MAAM,EAAE,CAAC,aAAa,QAAQ,GAAG,OAAO;CAEhE,OAAO;AACT"}
@@ -1,4 +1,3 @@
1
- import { ParticlesConfig } from "../config/model.js";
2
1
  import * as THREE from "three";
3
2
  //#region src/renderer/particleField.d.ts
4
3
  /**
@@ -19,32 +18,6 @@ interface ParticleFrame {
19
18
  right: THREE.Vector3;
20
19
  up: THREE.Vector3;
21
20
  }
22
- declare class ParticleField {
23
- readonly points: THREE.Points;
24
- private readonly geometry;
25
- private readonly material;
26
- /** Layout signature — only these rebuild the seeded buffers; the rest are live uniforms. */
27
- private sig;
28
- constructor();
29
- /** Reconcile to `cfg`: rebuild the seeded buffers if the layout signature changed, then push the
30
- * frame-independent uniforms. `loopSeconds` is scene-level (passed in). Called from refresh(). */
31
- sync(cfg: ParticlesConfig, loopSeconds: number): void;
32
- /** Push the per-frame frame basis (the camera can move, so this runs every frame). */
33
- frame(f: ParticleFrame, pixelRatio: number): void;
34
- /** Bind the OWNING wave's shape: mirror its shape #defines + shape uniforms + world matrix so the
35
- * dust rides the SAME deform as the ribbon. Recompiles the point program only when the define set
36
- * changes. Called from refresh() each frame with the wave's live state. */
37
- configure(shape: {
38
- defines: Record<string, string>;
39
- uniforms: Record<string, THREE.IUniform>;
40
- matrixWorld: THREE.Matrix4;
41
- speed: number;
42
- seed: number;
43
- }): void;
44
- /** Advance the field to scene time `t` (= the same `t` the waves get). */
45
- setTime(t: number): void;
46
- dispose(): void;
47
- }
48
21
  //#endregion
49
- export { ParticleField, ParticleFrame };
22
+ export { ParticleFrame };
50
23
  //# sourceMappingURL=particleField.d.ts.map
@@ -19,6 +19,17 @@ function setLinear(target, hex) {
19
19
  target.set(c.r, c.g, c.b);
20
20
  }
21
21
  const DEFAULT_COLOR = "#ffcf8a";
22
+ /**
23
+ * Edge of the square canvas a {@link ParticlesConfig.spriteUrl} is rasterized into. SVG has no
24
+ * intrinsic pixel size, so something has to choose one — this is it.
25
+ *
26
+ * 256² RGBA is ~256 KB (~350 KB with mipmaps), which is LESS than the per-particle attribute
27
+ * buffers a 20k field already uploads (~800 KB): one texture serves every particle in the field, so
28
+ * sprite artwork is not what makes a dust field expensive. 256 also covers the largest sprite the
29
+ * hardware will draw — gl.ALIASED_POINT_SIZE_RANGE tops out around 511 px, and `size` clamps to 200
30
+ * before the pixel-ratio multiply.
31
+ */
32
+ const SPRITE_PX = 256;
22
33
  /** Sprite-shape name → the int the fragment shader branches on (see particleFragmentShader). */
23
34
  const SHAPE_INDEX = {
24
35
  glitter: 0,
@@ -51,6 +62,28 @@ const SHAPE_UNIFORMS = [
51
62
  "uRadialRadius",
52
63
  "uRadialCenter"
53
64
  ];
65
+ /** The owning wave's POINTER-FIELD uniforms, mirrored the same way so the dust reads the exact
66
+ * cursor state the ribbon does (see pointerFieldChunk). Only uploaded under POINTER_FX — configure()
67
+ * copies them regardless, which is a handful of scalars. The ripple entries are ARRAYS: those are
68
+ * shared by reference (the wave owns them and applyPointerField mutates them in place), so a click
69
+ * costs no per-frame copy. Mirroring rather than a second CPU write also keeps capture determinism
70
+ * for free — applyInteractionRest zeroes the wave's uPointerActive / uRippleAmp, and the dust
71
+ * inherits that rest state on the same frame (updateSceneFx runs after applyInteraction). */
72
+ const POINTER_UNIFORMS = [
73
+ "uPointer",
74
+ "uPointerActive",
75
+ "uPointerRadius",
76
+ "uPointerAspect",
77
+ "uPointerAgitate",
78
+ "uPointerPush",
79
+ "uPointerWake",
80
+ "uPointerVel",
81
+ "uShapeFlow",
82
+ "uRippleOrigin",
83
+ "uRippleAge",
84
+ "uRippleAmp",
85
+ "uPointerRipple"
86
+ ];
54
87
  /** Build the seeded per-particle attribute buffers. Pure function of `(count, seed, edgeBias, bias)` —
55
88
  * exported so a unit test can assert reproducibility without a GPU. */
56
89
  function buildParticleAttributes(count, seed, edgeBias = 1, bias = 0) {
@@ -82,13 +115,68 @@ function buildParticleAttributes(count, seed, edgeBias = 1, bias = 0) {
82
115
  aUv
83
116
  };
84
117
  }
118
+ /**
119
+ * Rasterize `url` into a square {@link SPRITE_PX} texture.
120
+ *
121
+ * Deliberately the BACKGROUND-image pattern (load -> apply -> ask for a redraw) rather than a
122
+ * fire-and-forget TextureLoader: a thumbnail or poster snapshotted while the texture was still in
123
+ * flight would capture blank dust — the same class of bug that made image-driven preset thumbnails
124
+ * render empty.
125
+ *
126
+ * Shared by both backends' particle fields, so the CORS rule, the letterboxing and the mipmap
127
+ * settings have one home.
128
+ */
129
+ function loadSpriteTexture(url, onLoaded, onFailed) {
130
+ const img = new Image();
131
+ img.decoding = "async";
132
+ if (!url.startsWith("data:") && !url.startsWith("blob:")) img.crossOrigin = "anonymous";
133
+ img.addEventListener("load", () => {
134
+ const canvas = document.createElement("canvas");
135
+ canvas.width = SPRITE_PX;
136
+ canvas.height = SPRITE_PX;
137
+ const ctx = canvas.getContext("2d");
138
+ if (!ctx) return;
139
+ const iw = img.naturalWidth || SPRITE_PX;
140
+ const ih = img.naturalHeight || SPRITE_PX;
141
+ const fit = Math.min(SPRITE_PX / iw, SPRITE_PX / ih);
142
+ const w = iw * fit;
143
+ const h = ih * fit;
144
+ ctx.drawImage(img, (SPRITE_PX - w) / 2, (SPRITE_PX - h) / 2, w, h);
145
+ const tex = new THREE.CanvasTexture(canvas);
146
+ tex.colorSpace = THREE.SRGBColorSpace;
147
+ tex.generateMipmaps = true;
148
+ tex.minFilter = THREE.LinearMipmapLinearFilter;
149
+ tex.magFilter = THREE.LinearFilter;
150
+ tex.wrapS = THREE.ClampToEdgeWrapping;
151
+ tex.wrapT = THREE.ClampToEdgeWrapping;
152
+ onLoaded(tex);
153
+ }, { once: true });
154
+ img.addEventListener("error", onFailed, { once: true });
155
+ img.src = url;
156
+ }
85
157
  var ParticleField = class {
86
158
  points;
159
+ /** The scene node, named the same on both backends (the TSL field is a Sprite, not Points). */
160
+ get object() {
161
+ return this.points;
162
+ }
87
163
  geometry = new THREE.BufferGeometry();
88
164
  material;
89
165
  /** Layout signature — only these rebuild the seeded buffers; the rest are live uniforms. */
90
166
  sig = "";
91
- constructor() {
167
+ /** Rasterized user artwork for shape "sprite", and the url it came from (so a repeat sync is a
168
+ * no-op). `spriteFailedUrl` latches a broken image so a bad url is not retried every frame. */
169
+ sprite;
170
+ spriteUrl = "";
171
+ spriteFailedUrl = "";
172
+ disposed = false;
173
+ /** Defines this field owns (currently just PARTICLE_SPRITE), merged over the wave's in configure().
174
+ * Set only once a texture is actually bound, so the sampler never compiles without one. */
175
+ ownDefines = {};
176
+ /** Called when a sprite finishes rasterizing, so a paused/settled renderer redraws with it. */
177
+ onReady;
178
+ constructor(onReady) {
179
+ this.onReady = onReady;
92
180
  this.material = new THREE.ShaderMaterial({
93
181
  uniforms: {
94
182
  uTime: { value: 0 },
@@ -131,7 +219,22 @@ var ParticleField = class {
131
219
  uRadialCenter: { value: 0 },
132
220
  uShedModel: { value: new THREE.Matrix4() },
133
221
  uShedSpeed: { value: 0 },
134
- uShedSeed: { value: 0 }
222
+ uShedSeed: { value: 0 },
223
+ uPointer: { value: new THREE.Vector2() },
224
+ uPointerActive: { value: 0 },
225
+ uPointerRadius: { value: .6 },
226
+ uPointerAspect: { value: 1 },
227
+ uPointerAgitate: { value: 0 },
228
+ uPointerPush: { value: 0 },
229
+ uPointerWake: { value: 0 },
230
+ uPointerVel: { value: new THREE.Vector2() },
231
+ uShapeFlow: { value: 0 },
232
+ uRippleOrigin: { value: Array.from({ length: 4 }, () => new THREE.Vector2()) },
233
+ uRippleAge: { value: Array.from({ length: 4 }, () => 0) },
234
+ uRippleAmp: { value: Array.from({ length: 4 }, () => 0) },
235
+ uPointerRipple: { value: 0 },
236
+ uPartShove: { value: 1 },
237
+ uSprite: { value: null }
135
238
  },
136
239
  vertexShader: particleVertexShader,
137
240
  fragmentShader: particleFragmentShader,
@@ -172,8 +275,10 @@ var ParticleField = class {
172
275
  u.uSwirl.value = cfg.swirl ?? 0;
173
276
  u.uWander.value = cfg.wander ?? 0;
174
277
  u.uShape.value = SHAPE_INDEX[cfg.shape ?? "glitter"] ?? 0;
175
- setLinear(u.uColor.value, cfg.color ?? DEFAULT_COLOR);
176
- setLinear(u.uColor2.value, cfg.color2 ?? cfg.color ?? DEFAULT_COLOR);
278
+ u.uPartShove.value = cfg.pointerShove ?? 1;
279
+ this.syncSprite(cfg.shape === "sprite" ? cfg.spriteUrl ?? "" : "");
280
+ setLinear(u.uColor.value, cfg.color ?? "#ffcf8a");
281
+ setLinear(u.uColor2.value, cfg.color2 ?? cfg.color ?? "#ffcf8a");
177
282
  }
178
283
  /** Push the per-frame frame basis (the camera can move, so this runs every frame). */
179
284
  frame(f, pixelRatio) {
@@ -183,38 +288,90 @@ var ParticleField = class {
183
288
  u.uUp.value.copy(f.up);
184
289
  u.uPixelRatio.value = pixelRatio;
185
290
  }
186
- /** Bind the OWNING wave's shape: mirror its shape #defines + shape uniforms + world matrix so the
187
- * dust rides the SAME deform as the ribbon. Recompiles the point program only when the define set
188
- * changes. Called from refresh() each frame with the wave's live state. */
291
+ /** Bind the OWNING wave's shape AND cursor state: mirror its #defines + shape uniforms + pointer
292
+ * uniforms + world matrix, so the dust rides the same deform as the ribbon and reacts to the same
293
+ * pointer field. Recompiles the point program only when the define set changes — which is why the
294
+ * defines must come from CONFIG only (shapeDefines), never from live input. Called from refresh()
295
+ * each frame with the wave's live state. */
189
296
  configure(shape) {
190
- const want = shape.defines;
297
+ const want = {
298
+ ...shape.defines,
299
+ ...this.ownDefines
300
+ };
191
301
  const cur = this.material.defines ?? {};
192
302
  if (Object.keys(want).sort().join(",") !== Object.keys(cur).sort().join(",")) {
193
303
  this.material.defines = { ...want };
194
304
  this.material.needsUpdate = true;
195
305
  }
196
306
  const u = this.material.uniforms;
197
- for (const name of SHAPE_UNIFORMS) {
198
- const src = shape.uniforms[name];
199
- if (!src || u[name] === void 0) continue;
200
- const dst = u[name].value;
201
- if (typeof src.value === "number") u[name].value = src.value;
202
- else if (dst && typeof dst.copy === "function") dst.copy(src.value);
203
- }
307
+ for (const name of SHAPE_UNIFORMS) this.mirror(shape.uniforms, name);
308
+ for (const name of POINTER_UNIFORMS) this.mirror(shape.uniforms, name);
204
309
  u.uShedModel.value.copy(shape.matrixWorld);
205
310
  u.uShedSpeed.value = shape.speed;
206
311
  u.uShedSeed.value = shape.seed;
207
312
  }
313
+ /** Reconcile the sprite texture to `url` ("" = none). Cheap and idempotent: a repeat call with the
314
+ * same url does nothing, and a url that already failed is never retried. */
315
+ syncSprite(url) {
316
+ if (url === this.spriteUrl) return;
317
+ this.spriteUrl = url;
318
+ this.clearSprite();
319
+ if (url && url !== this.spriteFailedUrl) this.loadSprite(url);
320
+ }
321
+ /** Drop the current sprite and fall back to the procedural shapes (which is what `uShape` still
322
+ * holds, so a field mid-load or with a broken image draws "glitter" rather than nothing). */
323
+ clearSprite() {
324
+ if (!this.sprite) return;
325
+ this.sprite.dispose();
326
+ this.sprite = void 0;
327
+ this.material.uniforms.uSprite.value = null;
328
+ if (this.ownDefines.PARTICLE_SPRITE !== void 0) {
329
+ this.ownDefines = {};
330
+ this.material.needsUpdate = true;
331
+ }
332
+ }
333
+ /**
334
+ * Rasterize `url` into a square {@link SPRITE_PX} texture and bind it.
335
+ *
336
+ * Deliberately the BACKGROUND-image pattern (load → apply → ask for a redraw) rather than
337
+ * loadPaletteImage's fire-and-forget TextureLoader: a thumbnail or poster snapshotted while the
338
+ * texture was still in flight would capture blank dust — the same class of bug that made
339
+ * image-driven preset thumbnails render empty.
340
+ */
341
+ loadSprite(url) {
342
+ loadSpriteTexture(url, (tex) => {
343
+ if (this.disposed || this.spriteUrl !== url) return;
344
+ this.sprite = tex;
345
+ this.material.uniforms.uSprite.value = tex;
346
+ this.ownDefines = { PARTICLE_SPRITE: "" };
347
+ this.material.needsUpdate = true;
348
+ this.onReady?.();
349
+ }, () => {
350
+ this.spriteFailedUrl = url;
351
+ });
352
+ }
353
+ /** Copy one uniform across from the owning wave: numbers by value, vectors/matrices in place, and
354
+ * arrays (the ripple slots) by reference — the wave owns those and mutates them in place. */
355
+ mirror(src, name) {
356
+ const from = src[name];
357
+ const to = this.material.uniforms[name];
358
+ if (!from || !to) return;
359
+ const dst = to.value;
360
+ if (typeof from.value === "number" || Array.isArray(from.value)) to.value = from.value;
361
+ else if (dst && typeof dst.copy === "function") dst.copy(from.value);
362
+ }
208
363
  /** Advance the field to scene time `t` (= the same `t` the waves get). */
209
364
  setTime(t) {
210
365
  this.material.uniforms.uTime.value = t;
211
366
  }
212
367
  dispose() {
368
+ this.disposed = true;
369
+ this.sprite?.dispose();
213
370
  this.geometry.dispose();
214
371
  this.material.dispose();
215
372
  }
216
373
  };
217
374
  //#endregion
218
- export { ParticleField, buildParticleAttributes };
375
+ export { DEFAULT_COLOR, ParticleField, SHAPE_INDEX, buildParticleAttributes, loadSpriteTexture, setLinear };
219
376
 
220
377
  //# sourceMappingURL=particleField.js.map