@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
@@ -1,6 +1,7 @@
1
1
  import { ensureStudioConfig } from "../config/model.js";
2
- import { fragmentShader, lineFragmentShader, postFragmentShader, postVertexShader, vertexShader } from "./shaders.js";
2
+ import { ditherFragmentShader, fragmentShader, halftoneCmykFragmentShader, halftoneFragmentShader, heatmapFragmentShader, innerLightFragmentShader, lineFragmentShader, paperTextureFragmentShader, postFragmentShader, postVertexShader, vertexShader } from "./shaders.js";
3
3
  import { WaveGeometry } from "./WaveGeometry.js";
4
+ import { InteractionController, SCENE_APPLIERS, WAVE_APPLIERS, anyPointerFxActive, interactionActive, wavePointerFxActive, waveRipplesActive } from "./interaction.js";
4
5
  import { PALETTE_MAPS, buildBackgroundGradientCanvas, buildBackgroundImageCanvas, buildBackgroundMeshCanvas, buildPaletteTexture, canvasToTexture, configurePaletteTexture, drawBackgroundMediaFrame, loadPaletteImage, paletteMapCanvas, paletteSignature } from "./palette.js";
5
6
  import { buildHeroPaletteCanvas, buildHeroPaletteTexture } from "./heroPalette.js";
6
7
  import * as THREE from "three";
@@ -148,6 +149,12 @@ var WaveRenderer = class {
148
149
  postPass;
149
150
  /** Optional bloom pass — created lazily when bloomStrength first goes >0, removed at 0. */
150
151
  bloomPass;
152
+ ditherPass;
153
+ innerLightPass;
154
+ halftonePass;
155
+ heatmapPass;
156
+ paperTexturePass;
157
+ halftoneCmykPass;
151
158
  container;
152
159
  respectReducedMotion;
153
160
  skipIntroRamp;
@@ -169,6 +176,18 @@ var WaveRenderer = class {
169
176
  clipSphere = new THREE.Sphere();
170
177
  clipTmpA = new THREE.Vector3();
171
178
  clipTmpB = new THREE.Vector3();
179
+ /** Created by syncInteraction() when interaction turns on, disposed when it turns off. */
180
+ interaction;
181
+ /** Extra ortho-zoom MULTIPLIER from a cameraZoom binding (1 = none); applied in applyZoom().
182
+ * Protected so the studio's writeCameraToConfig() can divide it back out (keep it out of config). */
183
+ interactionZoom = 1;
184
+ /** Extra time-offset DELTA from a timeOffset binding (0 = none); added in updateTime(). */
185
+ interactionTimeOffset = 0;
186
+ /** Scene-binding out-params: appliers write into this, applyBindings() reads it back. */
187
+ interactionSceneOut = {
188
+ timeOffset: 0,
189
+ zoom: 1
190
+ };
172
191
  timer = new THREE.Timer();
173
192
  time = 0;
174
193
  rafId = 0;
@@ -276,6 +295,14 @@ var WaveRenderer = class {
276
295
  bandParams.push(new THREE.Vector4());
277
296
  bandParaPow.push(0);
278
297
  }
298
+ const rippleOrigin = [];
299
+ const rippleAge = [];
300
+ const rippleAmp = [];
301
+ for (let i = 0; i < 4; i++) {
302
+ rippleOrigin.push(new THREE.Vector2());
303
+ rippleAge.push(0);
304
+ rippleAmp.push(0);
305
+ }
279
306
  return {
280
307
  uTime: { value: 0 },
281
308
  uSpeed: { value: .05 },
@@ -342,7 +369,23 @@ var WaveRenderer = class {
342
369
  uLineThickness: { value: 1 },
343
370
  uLineDerivativePower: { value: .95 },
344
371
  uMaxWidth: { value: 1232 },
345
- uClearColor: { value: new THREE.Vector3(1, 1, 1) }
372
+ uClearColor: { value: new THREE.Vector3(1, 1, 1) },
373
+ uPointer: { value: new THREE.Vector2(0, 0) },
374
+ uPointerActive: { value: 0 },
375
+ uPointerRadius: { value: .6 },
376
+ uPointerAspect: { value: 1 },
377
+ uPointerAgitate: { value: 0 },
378
+ uPointerPush: { value: 0 },
379
+ uPointerWake: { value: 0 },
380
+ uPointerVel: { value: new THREE.Vector2(0, 0) },
381
+ uShapeFlow: { value: 0 },
382
+ uPointerThin: { value: 0 },
383
+ uPointerHue: { value: 0 },
384
+ uPointerLighten: { value: 0 },
385
+ uPointerRipple: { value: 0 },
386
+ uRippleOrigin: { value: rippleOrigin },
387
+ uRippleAge: { value: rippleAge },
388
+ uRippleAmp: { value: rippleAmp }
346
389
  };
347
390
  }
348
391
  /** Vertex-shader #defines for a wave: TWIST_MOTION (per-wave animated twist wobble) and
@@ -352,9 +395,14 @@ var WaveRenderer = class {
352
395
  const defines = {};
353
396
  if (sc?.twistMotion) defines.TWIST_MOTION = "";
354
397
  if ((this.config.loopSeconds ?? 0) > 0) defines.LOOP_MOTION = "";
355
- if ((sc?.detailAmount ?? 0) !== 0) defines.DETAIL_OCTAVE = "";
398
+ const bindsDetail = sc?.interaction?.bindings?.some((b) => b.target === "detailAmount") ?? false;
399
+ if ((sc?.detailAmount ?? 0) !== 0 || bindsDetail) defines.DETAIL_OCTAVE = "";
356
400
  if ((sc?.depthTint ?? 0) > 0) defines.DEPTH_TINT = "";
357
401
  if ((sc?.edgeFeather ?? .1) !== .1) defines.EDGE_FEATHER = "";
402
+ if (sc && wavePointerFxActive(this.config, sc)) {
403
+ defines.POINTER_FX = "";
404
+ if (waveRipplesActive(this.config, sc)) defines.POINTER_RIPPLES = "";
405
+ }
358
406
  return defines;
359
407
  }
360
408
  addWave() {
@@ -442,6 +490,7 @@ var WaveRenderer = class {
442
490
  refresh() {
443
491
  this.applyBackground();
444
492
  this.applyPost();
493
+ this.syncInteraction();
445
494
  if (!this.isCameraExternallyDriven()) {
446
495
  const p = this.config.cameraPosition;
447
496
  const tg = this.config.cameraTarget;
@@ -562,6 +611,23 @@ var WaveRenderer = class {
562
611
  wave.mesh.position.set(sc.position.x, sc.position.y, sc.position.z);
563
612
  u.uOpacity.value = sc.opacity;
564
613
  });
614
+ const sharedRadius = (this.config.interaction?.radius ?? .3) * 2;
615
+ const sharedFlow = this.config.interaction?.ribbonFlow ?? .8;
616
+ this.waves.forEach((wave, i) => {
617
+ const sc = this.config.waves[i] ?? this.config.waves[this.config.waves.length - 1];
618
+ if (!wavePointerFxActive(this.config, sc)) return;
619
+ const u = wave.material.uniforms;
620
+ const h = sc.interaction?.hover;
621
+ u.uPointerRadius.value = sharedRadius;
622
+ u.uPointerAgitate.value = h?.agitate ?? 0;
623
+ u.uPointerPush.value = h?.push ?? 0;
624
+ u.uPointerWake.value = h?.wake ?? 0;
625
+ u.uPointerThin.value = h?.thin ?? 0;
626
+ u.uPointerHue.value = h?.hueShift ?? 0;
627
+ u.uPointerLighten.value = h?.lighten ?? 0;
628
+ u.uPointerRipple.value = sc.interaction?.press?.ripple ?? 0;
629
+ u.uShapeFlow.value = sharedFlow;
630
+ });
565
631
  this.updatePaletteTextures();
566
632
  this.syncVideoPlayback();
567
633
  this.group.scale.set(this.config.mirrorH ? -1 : 1, this.config.mirrorV ? -1 : 1, 1);
@@ -833,6 +899,12 @@ var WaveRenderer = class {
833
899
  u.uGrainAmount.value = this.config.grain;
834
900
  u.uBlurSamples.value = Math.round(this.config.blurSamples ?? 6);
835
901
  this.applyBloom();
902
+ this.applyInnerLight();
903
+ this.applyHalftone();
904
+ this.applyHeatmap();
905
+ this.applyHalftoneCmyk();
906
+ this.applyPaperTexture();
907
+ this.applyDither();
836
908
  }
837
909
  /** Insert / tune / remove the bloom pass. strength 0 removes it from the composer entirely, so
838
910
  * cost and pixels are identical to bloom-off; the pass (and its mip-chain render targets) is
@@ -855,6 +927,172 @@ var WaveRenderer = class {
855
927
  this.bloomPass = void 0;
856
928
  }
857
929
  }
930
+ /** Insert / tune / remove the dithering pass — a self-contained "layered" post shader (an ordered
931
+ * Bayer dither, in the spirit of paper-design/shaders). Like bloom, dither 0 removes the pass
932
+ * entirely so cost and pixels match dither-off, and it's created lazily on first enable. It is
933
+ * appended AFTER OutputPass so it runs last and quantizes display-space colour (tone-mapped +
934
+ * sRGB) — dithering the linear composer buffer would crush the steps in the shadows. */
935
+ applyDither() {
936
+ const strength = this.config.dither ?? 0;
937
+ if (strength > 0) {
938
+ if (!this.ditherPass) {
939
+ this.ditherPass = new ShaderPass({
940
+ uniforms: {
941
+ tDiffuse: { value: null },
942
+ uResolution: { value: this.renderer.getDrawingBufferSize(new THREE.Vector2()) },
943
+ uDitherStrength: { value: strength },
944
+ uDitherScale: { value: this.config.ditherScale ?? 2 },
945
+ uDitherSteps: { value: this.config.ditherSteps ?? 4 }
946
+ },
947
+ vertexShader: postVertexShader,
948
+ fragmentShader: ditherFragmentShader
949
+ });
950
+ this.composer.addPass(this.ditherPass);
951
+ }
952
+ const u = this.ditherPass.uniforms;
953
+ u.uDitherStrength.value = strength;
954
+ u.uDitherScale.value = Math.max(1, this.config.ditherScale ?? 2);
955
+ u.uDitherSteps.value = Math.max(2, Math.round(this.config.ditherSteps ?? 4));
956
+ } else if (this.ditherPass) {
957
+ this.composer.removePass(this.ditherPass);
958
+ this.ditherPass.dispose();
959
+ this.ditherPass = void 0;
960
+ }
961
+ }
962
+ /** Insert / tune / remove the innerLight pass — volumetric light streaks scattered from the bright
963
+ * wave toward a light point (innerLightX/Y in UV). Scene zone (index 1) so it scatters the raw wave
964
+ * like bloom. innerLight 0 removes the pass entirely; created lazily on first enable. */
965
+ applyInnerLight() {
966
+ const strength = this.config.innerLight ?? 0;
967
+ if (strength > 0) {
968
+ const cx = this.config.innerLightX ?? .5;
969
+ const cy = this.config.innerLightY ?? .15;
970
+ if (!this.innerLightPass) {
971
+ this.innerLightPass = new ShaderPass({
972
+ uniforms: {
973
+ tDiffuse: { value: null },
974
+ uInnerLight: { value: strength },
975
+ uInnerLightDensity: { value: this.config.innerLightDensity ?? .5 },
976
+ uInnerLightDecay: { value: this.config.innerLightDecay ?? .95 },
977
+ uInnerLightCenter: { value: new THREE.Vector2(cx, cy) }
978
+ },
979
+ vertexShader: postVertexShader,
980
+ fragmentShader: innerLightFragmentShader
981
+ });
982
+ this.composer.insertPass(this.innerLightPass, 1);
983
+ }
984
+ const u = this.innerLightPass.uniforms;
985
+ u.uInnerLight.value = strength;
986
+ u.uInnerLightDensity.value = this.config.innerLightDensity ?? .5;
987
+ u.uInnerLightDecay.value = this.config.innerLightDecay ?? .95;
988
+ u.uInnerLightCenter.value.set(cx, cy);
989
+ } else if (this.innerLightPass) {
990
+ this.composer.removePass(this.innerLightPass);
991
+ this.innerLightPass.dispose();
992
+ this.innerLightPass = void 0;
993
+ }
994
+ }
995
+ /** Insert / tune / remove the halftone pass — a rotated dot screen (dot size scales with local
996
+ * brightness) over the finished image. halftone 0 removes the pass; created lazily on enable. */
997
+ applyHalftone() {
998
+ const strength = this.config.halftone ?? 0;
999
+ if (strength > 0) {
1000
+ if (!this.halftonePass) {
1001
+ this.halftonePass = new ShaderPass({
1002
+ uniforms: {
1003
+ tDiffuse: { value: null },
1004
+ uResolution: { value: this.renderer.getDrawingBufferSize(new THREE.Vector2()) },
1005
+ uHalftone: { value: strength },
1006
+ uHalftoneCell: { value: this.config.halftoneCell ?? 6 },
1007
+ uHalftoneAngle: { value: this.config.halftoneAngle ?? .4 }
1008
+ },
1009
+ vertexShader: postVertexShader,
1010
+ fragmentShader: halftoneFragmentShader
1011
+ });
1012
+ this.composer.addPass(this.halftonePass);
1013
+ }
1014
+ const u = this.halftonePass.uniforms;
1015
+ u.uHalftone.value = strength;
1016
+ u.uHalftoneCell.value = Math.max(2, this.config.halftoneCell ?? 6);
1017
+ u.uHalftoneAngle.value = this.config.halftoneAngle ?? .4;
1018
+ } else if (this.halftonePass) {
1019
+ this.composer.removePass(this.halftonePass);
1020
+ this.halftonePass.dispose();
1021
+ this.halftonePass = void 0;
1022
+ }
1023
+ }
1024
+ /** Heatmap: recolour the final image by luminance → thermal palette. Finish zone. */
1025
+ applyHeatmap() {
1026
+ const strength = this.config.heatmap ?? 0;
1027
+ if (strength > 0) {
1028
+ if (!this.heatmapPass) {
1029
+ this.heatmapPass = new ShaderPass({
1030
+ uniforms: {
1031
+ tDiffuse: { value: null },
1032
+ uHeatmap: { value: strength }
1033
+ },
1034
+ vertexShader: postVertexShader,
1035
+ fragmentShader: heatmapFragmentShader
1036
+ });
1037
+ this.composer.addPass(this.heatmapPass);
1038
+ }
1039
+ this.heatmapPass.uniforms.uHeatmap.value = strength;
1040
+ } else if (this.heatmapPass) {
1041
+ this.composer.removePass(this.heatmapPass);
1042
+ this.heatmapPass.dispose();
1043
+ this.heatmapPass = void 0;
1044
+ }
1045
+ }
1046
+ /** Paper texture: fibrous substrate shading multiplied over the image. Finish zone. */
1047
+ applyPaperTexture() {
1048
+ const strength = this.config.paperTexture ?? 0;
1049
+ if (strength > 0) {
1050
+ if (!this.paperTexturePass) {
1051
+ this.paperTexturePass = new ShaderPass({
1052
+ uniforms: {
1053
+ tDiffuse: { value: null },
1054
+ uPaper: { value: strength },
1055
+ uPaperScale: { value: this.config.paperTextureScale ?? 2 }
1056
+ },
1057
+ vertexShader: postVertexShader,
1058
+ fragmentShader: paperTextureFragmentShader
1059
+ });
1060
+ this.composer.addPass(this.paperTexturePass);
1061
+ }
1062
+ const u = this.paperTexturePass.uniforms;
1063
+ u.uPaper.value = strength;
1064
+ u.uPaperScale.value = Math.max(.5, this.config.paperTextureScale ?? 2);
1065
+ } else if (this.paperTexturePass) {
1066
+ this.composer.removePass(this.paperTexturePass);
1067
+ this.paperTexturePass.dispose();
1068
+ this.paperTexturePass = void 0;
1069
+ }
1070
+ }
1071
+ /** CMYK halftone: four rotated dot screens (cyan/magenta/yellow/black). Finish zone. */
1072
+ applyHalftoneCmyk() {
1073
+ const strength = this.config.halftoneCmyk ?? 0;
1074
+ if (strength > 0) {
1075
+ if (!this.halftoneCmykPass) {
1076
+ this.halftoneCmykPass = new ShaderPass({
1077
+ uniforms: {
1078
+ tDiffuse: { value: null },
1079
+ uHalftoneCmyk: { value: strength },
1080
+ uHalftoneCmykCell: { value: this.config.halftoneCmykCell ?? 6 }
1081
+ },
1082
+ vertexShader: postVertexShader,
1083
+ fragmentShader: halftoneCmykFragmentShader
1084
+ });
1085
+ this.composer.addPass(this.halftoneCmykPass);
1086
+ }
1087
+ const u = this.halftoneCmykPass.uniforms;
1088
+ u.uHalftoneCmyk.value = strength;
1089
+ u.uHalftoneCmykCell.value = Math.max(2, this.config.halftoneCmykCell ?? 6);
1090
+ } else if (this.halftoneCmykPass) {
1091
+ this.composer.removePass(this.halftoneCmykPass);
1092
+ this.halftoneCmykPass.dispose();
1093
+ this.halftoneCmykPass = void 0;
1094
+ }
1095
+ }
858
1096
  onResize = () => {
859
1097
  this.resize();
860
1098
  };
@@ -887,6 +1125,8 @@ var WaveRenderer = class {
887
1125
  const dw = w * dpr;
888
1126
  const dh = h * dpr;
889
1127
  this.postPass.uniforms.uResolution.value.set(dw, dh);
1128
+ if (this.ditherPass) this.ditherPass.uniforms.uResolution.value.set(dw, dh);
1129
+ if (this.halftonePass) this.halftonePass.uniforms.uResolution.value.set(dw, dh);
890
1130
  for (const s of this.waves) s.material.uniforms.uResolution.value.set(dw, dh);
891
1131
  this.camera.left = -dw / 2;
892
1132
  this.camera.right = dw / 2;
@@ -933,6 +1173,7 @@ var WaveRenderer = class {
933
1173
  }
934
1174
  if (!this.running) {
935
1175
  this.introTimeRamp = 1;
1176
+ this.interaction?.settle();
936
1177
  this.renderOnce();
937
1178
  }
938
1179
  this.syncVideoPlayback();
@@ -940,7 +1181,9 @@ var WaveRenderer = class {
940
1181
  loop = () => {
941
1182
  if (!this.running) return;
942
1183
  this.timer.update();
943
- this.time += this.timer.getDelta();
1184
+ const dt = this.timer.getDelta();
1185
+ this.time += dt;
1186
+ this.interaction?.update(dt);
944
1187
  if (this.introTimeRamp < 1) this.introTimeRamp = Math.min(1, this.introTimeRamp + .016);
945
1188
  this.renderOnce();
946
1189
  this.rafId = requestAnimationFrame(this.loop);
@@ -949,7 +1192,7 @@ var WaveRenderer = class {
949
1192
  * time = elapsed·introTimeRamp + timeOffset — the ramp eases the animation in on load. */
950
1193
  updateTime() {
951
1194
  const ramp = this.config.introRamp === false || this.skipIntroRamp ? 1 : this.introTimeRamp;
952
- const t = this.time * ramp + (this.config.timeOffset ?? 0);
1195
+ const t = this.time * ramp + (this.config.timeOffset ?? 0) + this.interactionTimeOffset;
953
1196
  for (let i = 0; i < this.waves.length; i++) {
954
1197
  const u = this.waves[i].material.uniforms;
955
1198
  u.uTime.value = t;
@@ -967,10 +1210,156 @@ var WaveRenderer = class {
967
1210
  renderOnce() {
968
1211
  this.updateBackgroundVideoFrame();
969
1212
  this.updateTime();
1213
+ this.applyInteraction();
970
1214
  this.updateClipPlanes();
971
1215
  this.composer.render();
972
1216
  this.onAfterRenderFrame();
973
1217
  }
1218
+ /** Create/dispose the interaction controller as config toggles interaction on/off. Called from
1219
+ * refresh(); the compiled define set (POINTER_FX etc.) is handled separately by waveDefines(). */
1220
+ syncInteraction() {
1221
+ const active = interactionActive(this.config);
1222
+ if (active && !this.interaction) this.interaction = new InteractionController(this.container, () => this.config);
1223
+ else if (!active && this.interaction) {
1224
+ this.interaction.dispose();
1225
+ this.interaction = void 0;
1226
+ this.interactionTimeOffset = 0;
1227
+ if (this.interactionZoom !== 1) {
1228
+ this.interactionZoom = 1;
1229
+ this.applyZoom();
1230
+ }
1231
+ }
1232
+ }
1233
+ /** Per-frame interaction write: dynamic pointer-field uniforms + bindings. No-op without a
1234
+ * controller. While capturing it writes the REST state instead (pointer field zeroed, every bound
1235
+ * param at its authored base) — merely skipping the write would freeze whatever live hover/scroll
1236
+ * state the previous frame left in the uniforms, so exports wouldn't be deterministic. */
1237
+ applyInteraction() {
1238
+ if (!this.interaction) return;
1239
+ if (this.capturing) {
1240
+ this.applyInteractionRest();
1241
+ return;
1242
+ }
1243
+ if (anyPointerFxActive(this.config)) this.applyPointerField(this.interaction);
1244
+ this.applyBindings(this.interaction);
1245
+ }
1246
+ /** Write the capture-frame interaction state: exactly what this config renders with no input —
1247
+ * pointer presence + ripple envelopes zeroed (vPointerFall gates every hover effect to 0) and each
1248
+ * bound param at its authored base. Live controller state is left untouched, so the frame after
1249
+ * the capture resumes mid-gesture; the trailing renderOnce() in captureImage restores the preview.
1250
+ * interactionZoom is deliberately NOT reset — captureImage strips it from camera.zoom itself, and
1251
+ * the post-capture restore depends on it being unchanged. */
1252
+ applyInteractionRest() {
1253
+ for (let i = 0; i < this.waves.length; i++) {
1254
+ const sc = this.config.waves[i] ?? this.config.waves[this.config.waves.length - 1];
1255
+ if (!wavePointerFxActive(this.config, sc)) continue;
1256
+ const u = this.waves[i].material.uniforms;
1257
+ u.uPointerActive.value = 0;
1258
+ const rAmp = u.uRippleAmp.value;
1259
+ for (let r = 0; r < 4; r++) rAmp[r] = 0;
1260
+ }
1261
+ const sceneBindings = this.config.interaction?.bindings;
1262
+ if (sceneBindings) {
1263
+ this.interactionSceneOut.timeOffset = this.config.timeOffset ?? 0;
1264
+ this.interactionSceneOut.zoom = this.config.cameraZoom ?? 1;
1265
+ const sceneArgs = {
1266
+ post: this.postPass.uniforms,
1267
+ out: this.interactionSceneOut
1268
+ };
1269
+ for (const b of sceneBindings) {
1270
+ const applier = SCENE_APPLIERS[b.target];
1271
+ applier.apply(applier.base(this.config), sceneArgs);
1272
+ }
1273
+ }
1274
+ if (this.interactionTimeOffset !== 0) {
1275
+ this.interactionTimeOffset = 0;
1276
+ this.updateTime();
1277
+ }
1278
+ for (let i = 0; i < this.waves.length; i++) {
1279
+ const sc = this.config.waves[i];
1280
+ const bindings = sc?.interaction?.bindings;
1281
+ if (!sc || !bindings || bindings.length === 0) continue;
1282
+ const wave = this.waves[i];
1283
+ for (const b of bindings) {
1284
+ const applier = WAVE_APPLIERS[b.target];
1285
+ applier.apply(applier.base(sc), {
1286
+ u: wave.material.uniforms,
1287
+ mesh: wave.mesh
1288
+ });
1289
+ }
1290
+ }
1291
+ }
1292
+ /** Write the dynamic pointer-field uniforms to every wave that HAS a pointer field. Position /
1293
+ * presence are PER WAVE (each trails the cursor at its own hover smoothing); ripple origins/ages
1294
+ * are shared. Per-wave amplitudes were already pushed statically in refresh(). */
1295
+ applyPointerField(ic) {
1296
+ const dw = this.camera.right - this.camera.left;
1297
+ const dh = this.camera.top - this.camera.bottom;
1298
+ const aspect = dh !== 0 ? dw / dh : 1;
1299
+ const ripples = ic.sample().ripples;
1300
+ const agitateDrive = Math.min(1, .2 + .85 * ic.pointerFlux());
1301
+ const vel = ic.pointerVelocity();
1302
+ for (let i = 0; i < this.waves.length; i++) {
1303
+ const sc = this.config.waves[i] ?? this.config.waves[this.config.waves.length - 1];
1304
+ if (!wavePointerFxActive(this.config, sc)) continue;
1305
+ const u = this.waves[i].material.uniforms;
1306
+ u.uPointerAgitate.value = (sc.interaction?.hover?.agitate ?? 0) * agitateDrive;
1307
+ u.uPointerVel.value.copy(vel);
1308
+ const f = ic.fieldFor(i);
1309
+ if (f) {
1310
+ u.uPointer.value.copy(f.ndc);
1311
+ u.uPointerActive.value = f.presence;
1312
+ } else u.uPointerActive.value = 0;
1313
+ u.uPointerAspect.value = aspect;
1314
+ const rOrigin = u.uRippleOrigin.value;
1315
+ const rAge = u.uRippleAge.value;
1316
+ const rAmp = u.uRippleAmp.value;
1317
+ for (let r = 0; r < 4; r++) {
1318
+ rOrigin[r].copy(ripples[r].origin);
1319
+ rAge[r] = ripples[r].age;
1320
+ rAmp[r] = ripples[r].amp;
1321
+ }
1322
+ }
1323
+ }
1324
+ /** Evaluate bindings via the applier tables: value = mix(from ?? base, to, smoothedSource). Scene
1325
+ * bindings drive scene params; each wave's bindings drive that wave's uniforms. */
1326
+ applyBindings(ic) {
1327
+ this.interactionSceneOut.timeOffset = this.config.timeOffset ?? 0;
1328
+ this.interactionSceneOut.zoom = this.config.cameraZoom ?? 1;
1329
+ const sceneArgs = {
1330
+ post: this.postPass.uniforms,
1331
+ out: this.interactionSceneOut
1332
+ };
1333
+ for (const b of this.config.interaction?.bindings ?? []) {
1334
+ const applier = SCENE_APPLIERS[b.target];
1335
+ const value = THREE.MathUtils.lerp(b.from ?? applier.base(this.config), b.to, ic.bindingValue(b));
1336
+ applier.apply(value, sceneArgs);
1337
+ }
1338
+ for (let i = 0; i < this.waves.length; i++) {
1339
+ const sc = this.config.waves[i];
1340
+ const bindings = sc?.interaction?.bindings;
1341
+ if (!sc || !bindings || bindings.length === 0) continue;
1342
+ const wave = this.waves[i];
1343
+ for (const b of bindings) {
1344
+ const applier = WAVE_APPLIERS[b.target];
1345
+ const value = THREE.MathUtils.lerp(b.from ?? applier.base(sc), b.to, ic.bindingValue(b));
1346
+ applier.apply(value, {
1347
+ u: wave.material.uniforms,
1348
+ mesh: wave.mesh
1349
+ });
1350
+ }
1351
+ }
1352
+ this.interactionTimeOffset = this.interactionSceneOut.timeOffset - (this.config.timeOffset ?? 0);
1353
+ const nextZoom = this.interactionSceneOut.zoom / (this.config.cameraZoom || 1);
1354
+ if (nextZoom !== this.interactionZoom) {
1355
+ this.interactionZoom = nextZoom;
1356
+ this.applyZoom();
1357
+ }
1358
+ }
1359
+ /** Feed a `custom:<name>` interaction input (developer API). No-op when interaction is off. */
1360
+ setInteractionInput(name, value) {
1361
+ this.interaction?.setInput(name, value);
1362
+ }
974
1363
  /** Re-evaluate play/pause after `config.paused` changes. */
975
1364
  refreshPlayback() {
976
1365
  this.updateRunning();
@@ -1005,7 +1394,7 @@ var WaveRenderer = class {
1005
1394
  applyZoom() {
1006
1395
  const dw = this.camera.right - this.camera.left;
1007
1396
  const dh = this.camera.top - this.camera.bottom;
1008
- this.camera.zoom = Math.max(dw / FRAME_W, dh / 750) * (this.config.cameraZoom ?? 1);
1397
+ this.camera.zoom = Math.max(dw / FRAME_W, dh / 750) * (this.config.cameraZoom ?? 1) * this.interactionZoom;
1009
1398
  this.camera.updateProjectionMatrix();
1010
1399
  }
1011
1400
  /** Fit the orthographic near/far planes to the scene before every render, so no part of a wave
@@ -1025,14 +1414,21 @@ var WaveRenderer = class {
1025
1414
  * it's a handful of vector ops over 1–8 meshes with no allocation. */
1026
1415
  updateClipPlanes() {
1027
1416
  this.clipBox.makeEmpty();
1028
- for (const wave of this.waves) {
1417
+ for (let i = 0; i < this.waves.length; i++) {
1418
+ const wave = this.waves[i];
1029
1419
  const mesh = wave.mesh;
1030
1420
  mesh.updateWorldMatrix(true, false);
1031
1421
  const bs = mesh.geometry.boundingSphere;
1032
1422
  if (!bs) continue;
1033
1423
  const center = this.clipTmpA.setFromMatrixPosition(mesh.matrixWorld);
1034
1424
  const disp = Math.abs(Number(wave.material.uniforms.uDispAmount.value) || 0);
1035
- const radius = (bs.center.length() + bs.radius + disp) * mesh.matrixWorld.getMaxScaleOnAxis() * 1.2;
1425
+ const sc = this.config.waves[i] ?? this.config.waves[this.config.waves.length - 1];
1426
+ let pointerDisp = 0;
1427
+ if (wavePointerFxActive(this.config, sc)) {
1428
+ const h = sc.interaction?.hover;
1429
+ pointerDisp = (h?.agitate ?? 0) + Math.abs(h?.push ?? 0) + (h?.wake ?? 0) + (sc.interaction?.press?.ripple ?? 0);
1430
+ }
1431
+ const radius = (bs.center.length() + bs.radius + disp + pointerDisp) * mesh.matrixWorld.getMaxScaleOnAxis() * 1.2;
1036
1432
  this.clipBox.expandByPoint(this.clipTmpB.copy(center).addScalar(radius));
1037
1433
  this.clipBox.expandByPoint(this.clipTmpB.copy(center).addScalar(-radius));
1038
1434
  }
@@ -1065,12 +1461,22 @@ var WaveRenderer = class {
1065
1461
  this.introTimeRamp = 1;
1066
1462
  }
1067
1463
  let blob = null;
1464
+ const prevZoom = this.camera.zoom;
1465
+ const strippingZoom = this.interactionZoom !== 1;
1068
1466
  try {
1069
1467
  this.capturing = true;
1468
+ if (strippingZoom) {
1469
+ this.camera.zoom = prevZoom / this.interactionZoom;
1470
+ this.camera.updateProjectionMatrix();
1471
+ }
1070
1472
  this.renderOnce();
1071
1473
  blob = await new Promise((resolve) => this.canvas.toBlob(resolve, mime, quality));
1072
1474
  } finally {
1073
1475
  this.capturing = false;
1476
+ if (strippingZoom) {
1477
+ this.camera.zoom = prevZoom;
1478
+ this.camera.updateProjectionMatrix();
1479
+ }
1074
1480
  if (transparent !== prevBg) {
1075
1481
  this.config.transparentBackground = prevBg;
1076
1482
  this.applyBackground();
@@ -1104,6 +1510,8 @@ var WaveRenderer = class {
1104
1510
  dispose() {
1105
1511
  cancelAnimationFrame(this.rafId);
1106
1512
  this.running = false;
1513
+ this.interaction?.dispose();
1514
+ this.interaction = void 0;
1107
1515
  this.resizeObserver.disconnect();
1108
1516
  this.intersectionObserver.disconnect();
1109
1517
  this.motionQuery.removeEventListener("change", this.onMotionChange);
@@ -1118,6 +1526,12 @@ var WaveRenderer = class {
1118
1526
  s.palette.dispose();
1119
1527
  }
1120
1528
  this.bloomPass?.dispose();
1529
+ this.ditherPass?.dispose();
1530
+ this.innerLightPass?.dispose();
1531
+ this.halftonePass?.dispose();
1532
+ this.heatmapPass?.dispose();
1533
+ this.paperTexturePass?.dispose();
1534
+ this.halftoneCmykPass?.dispose();
1121
1535
  this.composer.dispose();
1122
1536
  this.renderer.dispose();
1123
1537
  this.renderer.domElement.remove();