@wave3d/core 0.9.0 → 0.11.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 (53) hide show
  1. package/dist/config/model.d.ts +209 -6
  2. package/dist/config/model.js +85 -3
  3. package/dist/config/model.js.map +1 -1
  4. package/dist/index.d.ts +3 -2
  5. package/dist/index.js +3 -2
  6. package/dist/presets.js +295 -0
  7. package/dist/presets.js.map +1 -1
  8. package/dist/renderer/WaveGeometry.js +21 -0
  9. package/dist/renderer/WaveGeometry.js.map +1 -1
  10. package/dist/renderer/WaveRenderer.d.ts +62 -0
  11. package/dist/renderer/WaveRenderer.js +349 -10
  12. package/dist/renderer/WaveRenderer.js.map +1 -1
  13. package/dist/renderer/WaveRendererGPU.js +32 -2
  14. package/dist/renderer/WaveRendererGPU.js.map +1 -1
  15. package/dist/renderer/interaction.js +12 -0
  16. package/dist/renderer/interaction.js.map +1 -1
  17. package/dist/renderer/particleField.js +26 -2
  18. package/dist/renderer/particleField.js.map +1 -1
  19. package/dist/renderer/particleFieldGPU.js +6 -0
  20. package/dist/renderer/particleFieldGPU.js.map +1 -1
  21. package/dist/renderer/shaders.js +748 -13
  22. package/dist/renderer/shaders.js.map +1 -1
  23. package/dist/renderer/tsl/dissolve.js +56 -0
  24. package/dist/renderer/tsl/dissolve.js.map +1 -0
  25. package/dist/renderer/tsl/particleMaterial.js +46 -8
  26. package/dist/renderer/tsl/particleMaterial.js.map +1 -1
  27. package/dist/renderer/tsl/uniforms.js +40 -1
  28. package/dist/renderer/tsl/uniforms.js.map +1 -1
  29. package/dist/renderer/tsl/waveMaterial.js +275 -26
  30. package/dist/renderer/tsl/waveMaterial.js.map +1 -1
  31. package/dist/renderer/tsl/waveShape.js +31 -10
  32. package/dist/renderer/tsl/waveShape.js.map +1 -1
  33. package/dist/renderer/wavePath.js +189 -0
  34. package/dist/renderer/wavePath.js.map +1 -0
  35. package/dist/shell/createWave.d.ts +23 -3
  36. package/dist/shell/createWave.js +5 -4
  37. package/dist/shell/createWave.js.map +1 -1
  38. package/dist/shell/probe.d.ts +28 -0
  39. package/dist/shell/probe.js +58 -11
  40. package/dist/shell/probe.js.map +1 -1
  41. package/dist/standalone/wave3d.standalone.js +3401 -2108
  42. package/dist/standalone/wave3d.standalone.webgpu.js +7372 -5848
  43. package/dist/standalone.d.ts +2 -2
  44. package/dist/standalone.js +2 -2
  45. package/dist/studio/StudioWaveRenderer.d.ts +115 -8
  46. package/dist/studio/StudioWaveRenderer.js +588 -14
  47. package/dist/studio/StudioWaveRenderer.js.map +1 -1
  48. package/dist/studio/index.d.ts +2 -2
  49. package/dist/studio/index.js.map +1 -1
  50. package/dist/studio/randomize.js +0 -1
  51. package/dist/studio/randomize.js.map +1 -1
  52. package/package.json +1 -1
  53. package/skills/wave3d/SKILL.md +142 -5
@@ -1,9 +1,31 @@
1
1
  import { roundTo } from "../util/math.js";
2
2
  import { DEFAULT_LIGHT_POSITION, createLight } from "../config/model.js";
3
+ import { isClosedPath, samplePath, straightPath } from "../renderer/wavePath.js";
3
4
  import { WaveRenderer, hexToLinearVec3 } from "../renderer/WaveRenderer.js";
4
5
  import * as THREE from "three";
5
6
  //#region src/studio/StudioWaveRenderer.ts
6
7
  const MINIMAP_VANTAGE = new THREE.Vector3(.85, .6, 1).normalize();
8
+ /** Path handle size on screen, as a fraction of the view height (the dot's own radius is 0.32 of
9
+ * this — about 5 px on a laptop). */
10
+ const PATH_HANDLE_VIEW = .012;
11
+ /** Radius of a path point's pick target, in screen pixels. The dot is a precise thing to land on
12
+ * and nothing else competes for the spot, so the target under it is generous and fixed in pixels
13
+ * whatever the zoom. */
14
+ const PATH_PICK_PX = 14;
15
+ /** How much a hovered dot grows, so the point under the cursor is unmistakable. */
16
+ const PATH_HOVER_SCALE = 1.8;
17
+ /** Cursors for path mode, so the ribbon says what a click here does before anyone tries it: an
18
+ * arrow with a "+" badge over the ribbon (double-click adds a point, drag sculpts) and a "−" badge
19
+ * over a point (double-click removes it, drag moves it). Inline SVG data URLs with a keyword
20
+ * fallback, so nothing depends on an asset or a stylesheet. */
21
+ function badgedCursor(badge) {
22
+ const svg = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"26\" height=\"26\" viewBox=\"0 0 26 26\"><path d=\"M4 2.5v16l4.3-3.6 3 6.6 2.6-1.2-2.9-6.4 5.6-.1z\" fill=\"#fff\" stroke=\"#111\" stroke-width=\"1.3\" stroke-linejoin=\"round\"/><circle cx=\"19\" cy=\"19\" r=\"5.6\" fill=\"#fff\" stroke=\"#111\" stroke-width=\"1.3\"/>" + badge + "</svg>";
23
+ return `url("data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}") 4 2, pointer`;
24
+ }
25
+ const PATH_CURSOR = {
26
+ add: badgedCursor("<path d=\"M19 15.6v6.8M15.6 19h6.8\" stroke=\"#111\" stroke-width=\"1.8\" stroke-linecap=\"round\"/>"),
27
+ remove: badgedCursor("<path d=\"M15.6 19h6.8\" stroke=\"#111\" stroke-width=\"1.8\" stroke-linecap=\"round\"/>")
28
+ };
7
29
  var StudioWaveRenderer = class extends WaveRenderer {
8
30
  /** Set while the panel drives the camera, so orbit's 'change' doesn't re-refresh the
9
31
  * panel mid-drag (the panel already knows the new value). */
@@ -37,15 +59,39 @@ var StudioWaveRenderer = class extends WaveRenderer {
37
59
  /** Whether the main view orbit/zoom/pan is on (studio); off for the embed. */
38
60
  mainOrbitOn = false;
39
61
  lightHelpers = [];
40
- /** Which 3D-editing gizmo is active: none, dragging lights, or dragging the wave/waves. */
62
+ /** Which 3D-editing gizmo is active: none, dragging lights, dragging the wave/waves, or dragging
63
+ * one wave's PATH — the centreline it is swept along. */
41
64
  editMode = "none";
42
65
  selectedLight = 0;
43
66
  /** Wave/wave drag handles: index 0 = the whole-wave box (moves config.position); 1..N =
44
67
  * per-wave spheres (move each layer's offset), shown only when there's >1 wave. */
45
68
  waveHelpers = [];
46
69
  selectedWave = 0;
70
+ /** Path editing: one sphere per control point of `pathWave`'s centreline, plus the line through
71
+ * them. The handles live in WORLD space (that is what the drag machinery and the gizmo speak);
72
+ * the points they write are in the wave's local space, which is where a path is authored. */
73
+ pathHelpers = [];
74
+ /** Each path dot's larger, invisible pick target (a child of the dot), so a point is comfortable
75
+ * to land on without the dot itself growing into a boulder. */
76
+ pathPicks = [];
77
+ /** The dots' current screen-constant scale, so a hover can grow one and put it back. */
78
+ pathHandleScale = 1;
79
+ hoverPathPoint = -1;
80
+ pathLine;
81
+ pathWave = 0;
82
+ selectedPathPoint = 0;
83
+ /** Invisible pick surface following the CURRENT path — the wave's own mesh cannot be used, because
84
+ * its vertices are only deformed on the GPU, so a CPU raycast would hit the straight ribbon the
85
+ * geometry was born as rather than the curve on screen. */
86
+ pathProxy;
87
+ /** An in-flight sculpt: where on the ribbon it started (0..1 along the length), the last drag
88
+ * point, and how far along the ribbon the push reaches. */
89
+ sculptState;
47
90
  /** Gizmo operation: "translate" moves the handle, "rotate" spins the whole wave. */
48
91
  gizmoMode = "translate";
92
+ /** Wave scale at the start of a scale drag, with the helper scale it started from, so the
93
+ * drag applies as a RATIO — the helper is also resized every frame to stay screen-constant. */
94
+ scaleDragStart;
49
95
  /** Active free screen-plane drag of a handle (grab anywhere on the marker, camera locked). */
50
96
  dragState;
51
97
  dragPlane = new THREE.Plane();
@@ -60,12 +106,61 @@ var StudioWaveRenderer = class extends WaveRenderer {
60
106
  /** Set by the panel: fired after orbit moves the camera so sliders can refresh. */
61
107
  onCameraChanged;
62
108
  /** Set by the panel: fired after a wave/wave gizmo drag/selection so the position and
63
- * per-wave offset sliders can refresh. */
109
+ * per-wave offset sliders can refresh. Carries the selected wave's index — the panel uses it to
110
+ * reveal that wave's folder, the same way {@link onLightsChanged} drives the lights folder. */
64
111
  onWaveChanged;
112
+ /** Fires when path editing starts (the wave's index) or ends (-1), so the app can put the gestures
113
+ * on screen — none of them are discoverable from the canvas alone. */
114
+ onPathEditChanged;
115
+ /** Set by the panel: fired when whole-wave transform editing starts/stops (-1 = off), so the
116
+ * gestures can be shown and the panel's gizmo controls rebuilt. */
117
+ onWaveEditChanged;
65
118
  /** True while any drag-in-3D gizmo owns the camera (light or wave). */
66
119
  get editing() {
67
120
  return this.editMode !== "none";
68
121
  }
122
+ /** Edit one wave's PATH: show a handle per control point, draggable like every other gizmo. The
123
+ * wave takes a straight centreline first if it has none, so entering changes nothing on screen —
124
+ * the ribbon only moves once a point does. Pass -1 to leave. */
125
+ async setPathEditMode(waveIndex) {
126
+ if (waveIndex < 0) {
127
+ await this.setEditMode("none");
128
+ this.onPathEditChanged?.(-1);
129
+ return;
130
+ }
131
+ const wave = this.config.waves[waveIndex];
132
+ if (!wave) return;
133
+ if (!wave.path || wave.path.length < 2) wave.path = straightPath();
134
+ this.pathWave = waveIndex;
135
+ this.densifyPath(9);
136
+ this.selectedPathPoint = 0;
137
+ if (this.editMode === "path") {
138
+ this.syncPathHelpers();
139
+ this.refresh();
140
+ this.onPathEditChanged?.(waveIndex);
141
+ return;
142
+ }
143
+ await this.setEditMode("path");
144
+ this.refresh();
145
+ this.onPathEditChanged?.(waveIndex);
146
+ }
147
+ /** Which wave's path is being edited, or -1. */
148
+ pathEditWave() {
149
+ return this.editMode === "path" ? this.pathWave : -1;
150
+ }
151
+ /** Drop a wave's path — back to the straight centreline the geometry is born with. */
152
+ clearPath(waveIndex) {
153
+ const wave = this.config.waves[waveIndex];
154
+ if (!wave?.path) return;
155
+ delete wave.path;
156
+ if (this.editMode === "path" && this.pathWave === waveIndex) {
157
+ this.setEditMode("none");
158
+ this.onPathEditChanged?.(-1);
159
+ }
160
+ this.refresh();
161
+ this.onWaveChanged?.(this.selectedWave);
162
+ if (!this.running) this.renderOnce();
163
+ }
69
164
  isLightEditMode() {
70
165
  return this.editMode === "light";
71
166
  }
@@ -90,6 +185,7 @@ var StudioWaveRenderer = class extends WaveRenderer {
90
185
  if (this.transform) this.transform.enabled = false;
91
186
  this.transform?.detach();
92
187
  if (prev === "light") this.clearLightHelpers();
188
+ else if (prev === "path") this.clearPathHelpers();
93
189
  else this.clearWaveHelpers();
94
190
  }
95
191
  this.editMode = mode;
@@ -114,6 +210,9 @@ var StudioWaveRenderer = class extends WaveRenderer {
114
210
  this.syncLightHelpers();
115
211
  this.frameEditCamera();
116
212
  this.selectLight(Math.min(this.selectedLight, Math.max(0, this.lightHelpers.length - 1)));
213
+ } else if (mode === "path") {
214
+ this.syncPathHelpers();
215
+ this.selectPathHandle(0);
117
216
  } else {
118
217
  this.syncWaveHelpers();
119
218
  this.selectWaveHandle(Math.min(this.selectedWave, Math.max(0, this.waveHelpers.length - 1)));
@@ -134,13 +233,14 @@ var StudioWaveRenderer = class extends WaveRenderer {
134
233
  RIGHT: THREE.MOUSE.ROTATE
135
234
  };
136
235
  }
137
- /** Switch the wave-edit gizmo between moving handles and rotating the whole wave. Rotate
138
- * targets the whole-wave box (config.rotation), so selecting it makes the intent obvious. */
236
+ /** Switch the wave-edit gizmo between moving, rotating and resizing the whole wave. Rotate and
237
+ * scale target the whole-wave box (config.rotation / config.scale), so selecting one makes the
238
+ * intent obvious. */
139
239
  setGizmoMode(mode) {
140
240
  this.gizmoMode = mode;
141
241
  this.transform?.setMode(mode);
142
- this.transform?.setSpace(mode === "rotate" ? "local" : "world");
143
- if (mode === "rotate" && this.editMode === "wave") {
242
+ this.transform?.setSpace(mode === "translate" ? "world" : "local");
243
+ if (mode !== "translate" && this.editMode === "wave") {
144
244
  const waveIdx = this.waveHelpers.findIndex((h) => h.userData.kind === "wave");
145
245
  if (waveIdx >= 0) this.selectWaveHandle(waveIdx);
146
246
  }
@@ -180,11 +280,34 @@ var StudioWaveRenderer = class extends WaveRenderer {
180
280
  this.mainOrbitOn = true;
181
281
  this.renderer.domElement.style.cursor = "move";
182
282
  window.addEventListener("keydown", this.onKeyDown);
283
+ this.renderer.domElement.addEventListener("dblclick", this.onDoubleClick);
183
284
  await this.ensureOrbit();
184
285
  if (this.orbit && !this.editing) this.orbit.enabled = true;
185
286
  }
186
287
  /** Arrow keys orbit the camera around the target (←/→ azimuth, ↑/↓ elevation). */
187
288
  onKeyDown = (e) => {
289
+ if (e.key === "Escape" && (this.editMode === "path" || this.editMode === "wave")) {
290
+ e.preventDefault();
291
+ if (this.editMode === "path") {
292
+ const wave = this.pathWave;
293
+ this.onPathEditChanged?.(-1);
294
+ this.enterWaveEdit(wave);
295
+ } else this.leaveEditing();
296
+ return;
297
+ }
298
+ if (this.editMode === "wave" && !e.metaKey && !e.ctrlKey && !e.altKey) {
299
+ const t = e.target instanceof HTMLElement ? e.target : null;
300
+ if (!(t && (t.closest("#panel") || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))) {
301
+ const key = e.key.toLowerCase();
302
+ const mode = key === "g" ? "translate" : key === "r" ? "rotate" : key === "s" ? "scale" : "";
303
+ if (mode) {
304
+ e.preventDefault();
305
+ this.setGizmoMode(mode);
306
+ this.onWaveEditChanged?.(this.selectedWave);
307
+ return;
308
+ }
309
+ }
310
+ }
188
311
  if (!this.mainOrbitOn || !this.orbit || this.editing) return;
189
312
  const t = e.target instanceof HTMLElement ? e.target : null;
190
313
  if (t && (t.closest("#panel") || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
@@ -567,7 +690,16 @@ var StudioWaveRenderer = class extends WaveRenderer {
567
690
  this.transform = new TransformControls(this.camera, this.renderer.domElement);
568
691
  this.transform.setMode("translate");
569
692
  this.transform.addEventListener("dragging-changed", (e) => {
570
- if (this.orbit) this.orbit.enabled = !e.value;
693
+ const dragging = e.value;
694
+ if (this.orbit) this.orbit.enabled = !dragging;
695
+ if (dragging && this.gizmoMode === "scale" && this.editMode === "wave") {
696
+ const h = this.waveHelpers[this.selectedWave];
697
+ const sc = this.config.waves[this.selectedWave];
698
+ if (h && sc) this.scaleDragStart = {
699
+ helper: h.scale.clone(),
700
+ wave: { ...sc.scale }
701
+ };
702
+ } else if (!dragging) this.scaleDragStart = void 0;
571
703
  });
572
704
  this.transform.addEventListener("objectChange", this.onGizmoMoved);
573
705
  this.transform.addEventListener("change", this.onControlsChange);
@@ -610,23 +742,167 @@ var StudioWaveRenderer = class extends WaveRenderer {
610
742
  const rect = this.renderer.domElement.getBoundingClientRect();
611
743
  return new THREE.Vector2((ev.clientX - rect.left) / rect.width * 2 - 1, -((ev.clientY - rect.top) / rect.height) * 2 + 1);
612
744
  }
745
+ /**
746
+ * Double-click is the whole entry point to path editing: hit a ribbon and you are dragging its
747
+ * centreline, with no mode to find first. Inside path mode it keeps editing the path — on a point
748
+ * it removes that point, on the ribbon it inserts one where you clicked — so adding and removing
749
+ * are the same gesture as entering, and neither needs a button.
750
+ */
751
+ onDoubleClick = (ev) => {
752
+ if (!this.mainOrbitOn) return;
753
+ this.raycaster.setFromCamera(this.pointerNdc(ev), this.camera);
754
+ if (this.editMode === "path") {
755
+ const i = this.pickPathHandle();
756
+ const pts = this.config.waves[this.pathWave]?.path;
757
+ if (i >= 0 && pts) {
758
+ const closed = isClosedPath(pts);
759
+ if (i >= 0 && pts.length - (closed ? 1 : 0) > 2) {
760
+ pts.splice(i, 1);
761
+ if (closed && i === 0) pts[pts.length - 1] = { ...pts[0] };
762
+ this.afterPathEdit();
763
+ }
764
+ return;
765
+ }
766
+ const hit = this.raycastWave(this.pathWave);
767
+ if (hit && pts) {
768
+ this.insertPathPointAt(hit.point);
769
+ return;
770
+ }
771
+ }
772
+ const hitWave = this.pickWave();
773
+ if (hitWave < 0) {
774
+ this.leaveEditing();
775
+ return;
776
+ }
777
+ if (this.editMode === "path") {
778
+ this.setPathEditMode(hitWave);
779
+ return;
780
+ }
781
+ if (this.editMode === "wave" && hitWave === this.selectedWave) {
782
+ this.setPathEditMode(hitWave);
783
+ return;
784
+ }
785
+ this.enterWaveEdit(hitWave);
786
+ };
787
+ /** Select a wave and show its transform gizmo — the first stop of a double-click, and what the
788
+ * panel's "drag waves in 3D" toggle lands on. */
789
+ async enterWaveEdit(waveIndex) {
790
+ if (waveIndex < 0 || !this.config.waves[waveIndex]) return;
791
+ this.selectedWave = waveIndex;
792
+ if (this.editMode !== "wave") await this.setEditMode("wave");
793
+ this.selectWaveHandle(waveIndex);
794
+ this.onWaveEditChanged?.(waveIndex);
795
+ }
796
+ /** Leave whichever edit level is active and tell the panel about both. */
797
+ async leaveEditing() {
798
+ await this.setEditMode("none");
799
+ this.onPathEditChanged?.(-1);
800
+ this.onWaveEditChanged?.(-1);
801
+ }
802
+ /**
803
+ * Which wave a click meant. An exact hit on a wave's mesh wins, but that mesh is the UNDEFORMED
804
+ * ribbon — every twist, displacement, helix and path lives in the vertex shader — so on a deformed
805
+ * wave the exact test misses everything you can actually see. The fallback is the wave's bounding
806
+ * sphere, inflated the way the clip fit inflates it, which answers the only question entry really
807
+ * asks: which ribbon did they mean.
808
+ */
809
+ pickWave() {
810
+ for (let i = 0; i < this.waves.length; i++) if (this.raycastWave(i)) return i;
811
+ let best = -1;
812
+ let bestDist = Infinity;
813
+ for (let i = 0; i < this.waves.length; i++) {
814
+ if (this.editMode === "path" && i === this.pathWave) continue;
815
+ const wave = this.waves[i];
816
+ const bs = wave?.geometry.geometry.boundingSphere;
817
+ if (!bs) continue;
818
+ wave.mesh.updateWorldMatrix(true, false);
819
+ const sc = this.config.waves[i];
820
+ const inflate = Math.abs(sc?.displaceAmount ?? 0) + Math.abs(sc?.helixRadius ?? 0);
821
+ const sphere = new THREE.Sphere(bs.center.clone(), bs.radius + inflate).applyMatrix4(wave.mesh.matrixWorld);
822
+ const hit = new THREE.Vector3();
823
+ if (!this.raycaster.ray.intersectSphere(sphere, hit)) continue;
824
+ const d = hit.distanceToSquared(this.raycaster.ray.origin);
825
+ if (d < bestDist) {
826
+ bestDist = d;
827
+ best = i;
828
+ }
829
+ }
830
+ return best;
831
+ }
832
+ /** Raycast one wave's ribbon. In path mode that means the proxy strip, which follows the curve
833
+ * you can actually see; otherwise the wave's own (undeformed) mesh, which is enough to answer
834
+ * "which ribbon did I double-click". */
835
+ raycastWave(i) {
836
+ if (this.editMode === "path" && this.pathWave === i && this.pathProxy) return this.raycaster.intersectObject(this.pathProxy, false)[0];
837
+ const mesh = this.waves[i]?.mesh;
838
+ if (!mesh) return void 0;
839
+ return this.raycaster.intersectObject(mesh, false)[0];
840
+ }
841
+ /** Insert a control point where the ribbon was clicked, between the two points it falls between —
842
+ * so a double-click on a straight stretch gives you something to bend, exactly there. */
843
+ insertPathPointAt(worldPoint) {
844
+ const pts = this.config.waves[this.pathWave]?.path;
845
+ const mesh = this.waves[this.pathWave]?.mesh;
846
+ if (!pts || !mesh) return;
847
+ mesh.updateWorldMatrix(true, false);
848
+ const local = worldPoint.clone().applyMatrix4(new THREE.Matrix4().copy(mesh.matrixWorld).invert());
849
+ let best = 1;
850
+ let bestD = Infinity;
851
+ for (let i = 1; i < pts.length; i++) {
852
+ const mx = (pts[i - 1].x + pts[i].x) / 2;
853
+ const my = (pts[i - 1].y + pts[i].y) / 2;
854
+ const mz = (pts[i - 1].z + pts[i].z) / 2;
855
+ const d = Math.hypot(local.x - mx, local.y - my, local.z - mz);
856
+ if (d < bestD) {
857
+ bestD = d;
858
+ best = i;
859
+ }
860
+ }
861
+ pts.splice(best, 0, {
862
+ x: roundTo(local.x, 2),
863
+ y: roundTo(local.y, 2),
864
+ z: roundTo(local.z, 2),
865
+ width: pts[best - 1].width ?? 1,
866
+ twist: pts[best - 1].twist ?? 0
867
+ });
868
+ this.selectedPathPoint = best;
869
+ this.afterPathEdit();
870
+ }
871
+ /** Shared tail of every structural path edit: rebake, re-handle, tell the app. */
872
+ afterPathEdit() {
873
+ this.refresh();
874
+ this.syncPathHelpers();
875
+ this.selectPathHandle(this.selectedPathPoint);
876
+ this.onWaveChanged?.(this.selectedWave);
877
+ if (!this.running) this.renderOnce();
878
+ }
613
879
  onPointerDown = (ev) => {
614
880
  if (!this.editing || !this.transform) return;
615
881
  if (ev.button !== 0) return;
616
882
  if (this.transform.dragging || this.transform.axis) return;
617
883
  this.raycaster.setFromCamera(this.pointerNdc(ev), this.camera);
618
- const helpers = this.editMode === "wave" ? this.waveHelpers : this.lightHelpers;
619
- const hit = this.raycaster.intersectObjects(helpers, false)[0];
620
- if (!hit) {
884
+ const helpers = this.editMode === "wave" ? this.waveHelpers : this.editMode === "path" ? this.pathHelpers : this.lightHelpers;
885
+ const pathIdx = this.editMode === "path" ? this.pickPathHandle() : -1;
886
+ const hit = this.editMode === "path" ? void 0 : this.raycaster.intersectObjects(helpers, false)[0];
887
+ const hitAny = pathIdx >= 0 || !!hit;
888
+ if (!hitAny && this.editMode === "path") {
889
+ const onRibbon = this.raycastWave(this.pathWave);
890
+ if (onRibbon?.uv) {
891
+ this.beginSculpt(onRibbon, ev);
892
+ return;
893
+ }
894
+ }
895
+ if (!hitAny) {
621
896
  if (this.orbit) {
622
897
  this.panState = { lastNdc: this.pointerNdc(ev) };
623
898
  this.renderer.domElement.setPointerCapture?.(ev.pointerId);
624
899
  }
625
900
  return;
626
901
  }
627
- const idx = helpers.indexOf(hit.object);
902
+ const idx = pathIdx >= 0 ? pathIdx : hit ? helpers.indexOf(hit.object) : -1;
628
903
  if (idx < 0) return;
629
904
  if (this.editMode === "wave") this.selectWaveHandle(idx);
905
+ else if (this.editMode === "path") this.selectPathHandle(idx);
630
906
  else this.selectLight(idx);
631
907
  if (this.gizmoMode !== "translate") return;
632
908
  const helper = helpers[idx];
@@ -642,6 +918,15 @@ var StudioWaveRenderer = class extends WaveRenderer {
642
918
  this.renderer.domElement.setPointerCapture?.(ev.pointerId);
643
919
  };
644
920
  onPointerMove = (ev) => {
921
+ if (this.sculptState) {
922
+ this.renderer.domElement.style.cursor = "grabbing";
923
+ this.sculptTo(ev);
924
+ return;
925
+ }
926
+ if (this.editMode === "path" && !this.dragState && !this.panState) {
927
+ this.raycaster.setFromCamera(this.pointerNdc(ev), this.camera);
928
+ this.renderer.domElement.style.cursor = this.pathCursorAt();
929
+ }
645
930
  if (this.panState) {
646
931
  const ndc = this.pointerNdc(ev);
647
932
  const before = new THREE.Vector3(this.panState.lastNdc.x, this.panState.lastNdc.y, 0);
@@ -667,6 +952,13 @@ var StudioWaveRenderer = class extends WaveRenderer {
667
952
  if (!this.running) this.renderOnce();
668
953
  };
669
954
  onPointerUp = (ev) => {
955
+ if (this.sculptState) {
956
+ this.sculptState = void 0;
957
+ this.renderer.domElement.style.cursor = PATH_CURSOR.add;
958
+ if (this.orbit) this.orbit.enabled = true;
959
+ this.renderer.domElement.releasePointerCapture?.(ev.pointerId);
960
+ return;
961
+ }
670
962
  if (this.panState) {
671
963
  this.panState = void 0;
672
964
  this.renderer.domElement.releasePointerCapture?.(ev.pointerId);
@@ -690,14 +982,286 @@ var StudioWaveRenderer = class extends WaveRenderer {
690
982
  const h = this.waveHelpers[i];
691
983
  if (h && this.transform) this.transform.attach(h);
692
984
  else this.transform?.detach();
693
- this.onWaveChanged?.();
985
+ this.onWaveChanged?.(this.selectedWave);
694
986
  if (!this.running) this.renderOnce();
695
987
  }
696
988
  /** Gizmo drag → route to the active mode's writer. */
697
989
  onGizmoMoved = () => {
698
990
  if (this.editMode === "wave") this.onWaveGizmoMoved();
991
+ else if (this.editMode === "path") this.onPathGizmoMoved();
699
992
  else this.onLightGizmoMoved();
700
993
  };
994
+ /** Path handle drag → write the point back in the wave's LOCAL space. The handles are dragged in
995
+ * world space (that is what the gizmo and the screen-plane drag speak), so each one is pushed
996
+ * back through the wave's own matrix — which is what keeps a path authored against the ribbon
997
+ * rather than against the scene, so moving or rotating the wave carries its path along. */
998
+ onPathGizmoMoved() {
999
+ const h = this.pathHelpers[this.selectedPathPoint];
1000
+ const pts = this.config.waves[this.pathWave]?.path;
1001
+ if (!h || !pts) return;
1002
+ const mesh = this.waves[this.pathWave]?.mesh;
1003
+ if (!mesh) return;
1004
+ mesh.updateWorldMatrix(true, false);
1005
+ const local = h.position.clone().applyMatrix4(new THREE.Matrix4().copy(mesh.matrixWorld).invert());
1006
+ const p = pts[this.selectedPathPoint];
1007
+ if (!p) return;
1008
+ p.x = roundTo(local.x, 2);
1009
+ p.y = roundTo(local.y, 2);
1010
+ p.z = roundTo(local.z, 2);
1011
+ const last = pts.length - 1;
1012
+ if (isClosedPath(pts)) {
1013
+ if (this.selectedPathPoint === 0) Object.assign(pts[last], {
1014
+ x: p.x,
1015
+ y: p.y,
1016
+ z: p.z
1017
+ });
1018
+ else if (this.selectedPathPoint === last) Object.assign(pts[0], {
1019
+ x: p.x,
1020
+ y: p.y,
1021
+ z: p.z
1022
+ });
1023
+ }
1024
+ this.refresh();
1025
+ this.syncPathHelpers();
1026
+ this.onWaveChanged?.(this.selectedWave);
1027
+ }
1028
+ /**
1029
+ * Start pushing the ribbon around from wherever it was grabbed.
1030
+ *
1031
+ * The path is DENSIFIED first: a three-point path can only be bent as a whole, and a push that
1032
+ * moves the entire ribbon is not sculpting. Resampling to evenly spaced points along the current
1033
+ * curve keeps the shape identical (the curve is unchanged) while giving the falloff something local
1034
+ * to act on — the difference between dragging a wire and pressing a thumb into clay.
1035
+ */
1036
+ beginSculpt(hit, ev) {
1037
+ const wave = this.config.waves[this.pathWave];
1038
+ if (!wave?.path || !hit.uv) return;
1039
+ this.densifyPath(wave.path.length < 12 ? 15 : wave.path.length);
1040
+ const normal = this.camera.getWorldDirection(new THREE.Vector3());
1041
+ this.dragPlane.setFromNormalAndCoplanarPoint(normal, hit.point);
1042
+ this.sculptState = {
1043
+ s: hit.uv.y,
1044
+ last: hit.point.clone(),
1045
+ radius: ev.shiftKey ? .1 : .3
1046
+ };
1047
+ if (this.orbit) this.orbit.enabled = false;
1048
+ this.renderer.domElement.setPointerCapture?.(ev.pointerId);
1049
+ }
1050
+ /** Resample the path to `count` evenly spaced points along the curve it already describes — same
1051
+ * shape, more to grab. Width and twist ride along so a sculpted throat survives densifying. */
1052
+ densifyPath(count) {
1053
+ const wave = this.config.waves[this.pathWave];
1054
+ const pts = wave?.path;
1055
+ if (!pts || pts.length >= count) return;
1056
+ const closed = isClosedPath(pts);
1057
+ const next = samplePath(pts, count).map((f) => ({
1058
+ x: roundTo(f.pos.x, 2),
1059
+ y: roundTo(f.pos.y, 2),
1060
+ z: roundTo(f.pos.z, 2),
1061
+ width: roundTo(f.width, 3),
1062
+ twist: roundTo(f.twist, 2)
1063
+ }));
1064
+ if (closed) next[next.length - 1] = { ...next[0] };
1065
+ wave.path = next;
1066
+ }
1067
+ /** Move the path under an in-flight sculpt: every point near the grabbed spot follows the cursor,
1068
+ * falling off smoothly with distance ALONG the ribbon. */
1069
+ sculptTo(ev) {
1070
+ const st = this.sculptState;
1071
+ const wave = this.config.waves[this.pathWave];
1072
+ const mesh = this.waves[this.pathWave]?.mesh;
1073
+ const pts = wave?.path;
1074
+ if (!st || !pts || !mesh) return;
1075
+ this.raycaster.setFromCamera(this.pointerNdc(ev), this.camera);
1076
+ const now = new THREE.Vector3();
1077
+ if (!this.raycaster.ray.intersectPlane(this.dragPlane, now)) return;
1078
+ mesh.updateWorldMatrix(true, false);
1079
+ const inv = new THREE.Matrix4().copy(mesh.matrixWorld).invert();
1080
+ const d = now.clone().applyMatrix4(inv).sub(st.last.clone().applyMatrix4(inv));
1081
+ const closed = isClosedPath(pts);
1082
+ const n = pts.length;
1083
+ const lastIdx = n - 1;
1084
+ for (let i = 0; i < n; i++) {
1085
+ const si = i / lastIdx;
1086
+ let ds = Math.abs(si - st.s);
1087
+ if (closed) ds = Math.min(ds, 1 - ds);
1088
+ const t = Math.min(1, ds / st.radius);
1089
+ const w = 1 - t * t * (3 - 2 * t);
1090
+ if (w <= 0) continue;
1091
+ pts[i].x = roundTo(pts[i].x + d.x * w, 2);
1092
+ pts[i].y = roundTo(pts[i].y + d.y * w, 2);
1093
+ pts[i].z = roundTo(pts[i].z + d.z * w, 2);
1094
+ }
1095
+ if (closed) pts[lastIdx] = {
1096
+ ...pts[0],
1097
+ width: pts[lastIdx].width,
1098
+ twist: pts[lastIdx].twist
1099
+ };
1100
+ st.last.copy(now);
1101
+ this.refresh();
1102
+ this.syncPathHelpers();
1103
+ this.onWaveChanged?.(this.selectedWave);
1104
+ if (!this.running) this.renderOnce();
1105
+ }
1106
+ /** True when the path's ends coincide — the same test the sampler uses to decide it is a ring. */
1107
+ /** Select a path point (attach the gizmo to its handle and highlight it). */
1108
+ /** Which path point the ray is over, through the enlarged pick targets — or -1. The raycaster
1109
+ * must already be aimed. */
1110
+ pickPathHandle() {
1111
+ const hit = this.raycaster.intersectObjects(this.pathPicks, false)[0];
1112
+ return hit ? hit.object.userData.index ?? -1 : -1;
1113
+ }
1114
+ /** The cursor for the pointer's current spot in path mode: what a click here would do. The
1115
+ * raycaster must already be aimed. */
1116
+ pathCursorAt() {
1117
+ const onPoint = this.pickPathHandle();
1118
+ if (onPoint !== this.hoverPathPoint) {
1119
+ this.hoverPathPoint = onPoint;
1120
+ const r = this.pathHandleScale;
1121
+ this.pathHelpers.forEach((h, k) => h.scale.setScalar(k === onPoint ? r * PATH_HOVER_SCALE : r));
1122
+ if (!this.running) this.renderOnce();
1123
+ }
1124
+ if (onPoint >= 0) {
1125
+ const pts = this.config.waves[this.pathWave]?.path;
1126
+ return !!pts && pts.length - (isClosedPath(pts) ? 1 : 0) > 2 ? PATH_CURSOR.remove : "pointer";
1127
+ }
1128
+ return this.raycastWave(this.pathWave) ? PATH_CURSOR.add : "move";
1129
+ }
1130
+ selectPathHandle(i) {
1131
+ this.selectedPathPoint = Math.max(0, Math.min(i, this.pathHelpers.length - 1));
1132
+ const sel = this.pathHelpers[this.selectedPathPoint];
1133
+ if (sel?.parent && this.transform) this.transform.attach(sel);
1134
+ this.pathHelpers.forEach((h, k) => {
1135
+ h.material.color.set(k === this.selectedPathPoint ? 16762941 : 3789055);
1136
+ });
1137
+ if (!this.running) this.renderOnce();
1138
+ }
1139
+ /** Reconcile the path handles + the line through them with the wave's points. */
1140
+ syncPathHelpers() {
1141
+ if (this.transform?.dragging) return;
1142
+ const pts = this.config.waves[this.pathWave]?.path ?? [];
1143
+ const mesh = this.waves[this.pathWave]?.mesh;
1144
+ if (!mesh || pts.length < 2) {
1145
+ this.clearPathHelpers();
1146
+ return;
1147
+ }
1148
+ mesh.updateWorldMatrix(true, false);
1149
+ const handleCount = isClosedPath(pts) ? pts.length - 1 : pts.length;
1150
+ if (this.pathHelpers.length !== handleCount) {
1151
+ this.clearPathHelpers();
1152
+ for (let i = 0; i < handleCount; i++) {
1153
+ const dot = new THREE.Mesh(new THREE.SphereGeometry(.32, 16, 12), new THREE.MeshBasicMaterial({
1154
+ color: 3789055,
1155
+ depthTest: false,
1156
+ transparent: true
1157
+ }));
1158
+ dot.renderOrder = 999;
1159
+ dot.userData = {
1160
+ kind: "path",
1161
+ index: i
1162
+ };
1163
+ this.overlay.add(dot);
1164
+ this.pathHelpers.push(dot);
1165
+ const pick = new THREE.Mesh(new THREE.SphereGeometry(1, 12, 8));
1166
+ pick.visible = false;
1167
+ pick.userData = {
1168
+ kind: "path",
1169
+ index: i
1170
+ };
1171
+ dot.add(pick);
1172
+ this.pathPicks.push(pick);
1173
+ }
1174
+ }
1175
+ const r = (this.camera.top - this.camera.bottom) / Math.max(this.camera.zoom, 1e-6) * PATH_HANDLE_VIEW;
1176
+ this.pathHandleScale = r;
1177
+ const canvasH = Math.max(1, this.renderer.domElement.clientHeight);
1178
+ const pickLocal = PATH_PICK_PX / (PATH_HANDLE_VIEW * canvasH);
1179
+ this.pathHelpers.forEach((h, i) => {
1180
+ const p = pts[i];
1181
+ h.position.set(p.x, p.y, p.z).applyMatrix4(mesh.matrixWorld);
1182
+ h.scale.setScalar(i === this.hoverPathPoint ? r * PATH_HOVER_SCALE : r);
1183
+ });
1184
+ for (const pick of this.pathPicks) pick.scale.setScalar(pickLocal);
1185
+ const frames = samplePath(pts, 96);
1186
+ const verts = frames.map((f) => f.pos.clone().applyMatrix4(mesh.matrixWorld));
1187
+ if (!this.pathLine) {
1188
+ this.pathLine = new THREE.Line(new THREE.BufferGeometry(), new THREE.LineBasicMaterial({
1189
+ color: 3789055,
1190
+ depthTest: false,
1191
+ transparent: true
1192
+ }));
1193
+ this.pathLine.renderOrder = 998;
1194
+ this.overlay.add(this.pathLine);
1195
+ }
1196
+ this.pathLine.geometry.setFromPoints(verts);
1197
+ this.syncPathProxy(frames, mesh);
1198
+ this.selectPathHandle(Math.min(this.selectedPathPoint, this.pathHelpers.length - 1));
1199
+ }
1200
+ /** Rebuild the pick surface: a strip two vertices wide per frame, carrying uv.y = position along
1201
+ * the ribbon — which is what a sculpt drag needs to know to push the right part of the path. It
1202
+ * belongs to no scene (nothing should draw it); raycasting only needs its world matrix. */
1203
+ syncPathProxy(frames, mesh) {
1204
+ const n = frames.length;
1205
+ const pos = new Float32Array(n * 2 * 3);
1206
+ const uv = new Float32Array(n * 2 * 2);
1207
+ const idx = [];
1208
+ const half = 92;
1209
+ for (let i = 0; i < n; i++) {
1210
+ const f = frames[i];
1211
+ const w = half * f.width;
1212
+ for (let k = 0; k < 2; k++) {
1213
+ const o = (i * 2 + k) * 3;
1214
+ const sign = k === 0 ? -1 : 1;
1215
+ pos[o] = f.pos.x + f.binormal.x * w * sign;
1216
+ pos[o + 1] = f.pos.y + f.binormal.y * w * sign;
1217
+ pos[o + 2] = f.pos.z + f.binormal.z * w * sign;
1218
+ uv[(i * 2 + k) * 2] = k;
1219
+ uv[(i * 2 + k) * 2 + 1] = i / (n - 1);
1220
+ }
1221
+ if (i > 0) {
1222
+ const a = (i - 1) * 2;
1223
+ idx.push(a, a + 1, a + 2, a + 1, a + 3, a + 2);
1224
+ }
1225
+ }
1226
+ if (!this.pathProxy) {
1227
+ this.pathProxy = new THREE.Mesh(new THREE.BufferGeometry(), new THREE.MeshBasicMaterial({ side: THREE.DoubleSide }));
1228
+ this.pathProxy.visible = false;
1229
+ }
1230
+ const g = this.pathProxy.geometry;
1231
+ g.setAttribute("position", new THREE.BufferAttribute(pos, 3));
1232
+ g.setAttribute("uv", new THREE.BufferAttribute(uv, 2));
1233
+ g.setIndex(idx);
1234
+ g.computeBoundingSphere();
1235
+ this.pathProxy.matrix.copy(mesh.matrixWorld);
1236
+ this.pathProxy.matrixAutoUpdate = false;
1237
+ this.pathProxy.matrixWorld.copy(mesh.matrixWorld);
1238
+ }
1239
+ clearPathHelpers() {
1240
+ if (this.transform?.object && this.pathHelpers.includes(this.transform.object)) this.transform.detach();
1241
+ for (const mesh of this.pathHelpers) {
1242
+ this.overlay.remove(mesh);
1243
+ mesh.geometry.dispose();
1244
+ mesh.material.dispose();
1245
+ }
1246
+ this.pathHelpers = [];
1247
+ for (const pick of this.pathPicks) {
1248
+ pick.geometry.dispose();
1249
+ pick.material.dispose();
1250
+ }
1251
+ this.pathPicks = [];
1252
+ this.hoverPathPoint = -1;
1253
+ if (this.pathProxy) {
1254
+ this.pathProxy.geometry.dispose();
1255
+ this.pathProxy.material.dispose();
1256
+ this.pathProxy = void 0;
1257
+ }
1258
+ if (this.pathLine) {
1259
+ this.overlay.remove(this.pathLine);
1260
+ this.pathLine.geometry.dispose();
1261
+ this.pathLine.material.dispose();
1262
+ this.pathLine = void 0;
1263
+ }
1264
+ }
701
1265
  /** Light gizmo drag → write the moved handle back into the config + uniforms. */
702
1266
  onLightGizmoMoved() {
703
1267
  const h = this.lightHelpers[this.selectedLight];
@@ -720,13 +1284,20 @@ var StudioWaveRenderer = class extends WaveRenderer {
720
1284
  wave.rotation.x = roundTo(THREE.MathUtils.radToDeg(h.rotation.x), 2);
721
1285
  wave.rotation.y = roundTo(THREE.MathUtils.radToDeg(h.rotation.y), 2);
722
1286
  wave.rotation.z = roundTo(THREE.MathUtils.radToDeg(h.rotation.z), 2);
1287
+ } else if (this.gizmoMode === "scale") {
1288
+ const st = this.scaleDragStart;
1289
+ if (!st) return;
1290
+ const MIN = .01;
1291
+ wave.scale.x = roundTo(Math.max(MIN, st.wave.x * h.scale.x / st.helper.x), 3);
1292
+ wave.scale.y = roundTo(Math.max(MIN, st.wave.y * h.scale.y / st.helper.y), 3);
1293
+ wave.scale.z = roundTo(Math.max(MIN, st.wave.z * h.scale.z / st.helper.z), 3);
723
1294
  } else {
724
1295
  wave.position.x = roundTo(h.position.x, 2);
725
1296
  wave.position.y = roundTo(h.position.y, 2);
726
1297
  wave.position.z = roundTo(h.position.z, 2);
727
1298
  }
728
1299
  this.pushWaveTransforms();
729
- this.onWaveChanged?.();
1300
+ this.onWaveChanged?.(this.selectedWave);
730
1301
  }
731
1302
  /** Reconcile the helper spheres with config.lights (count, position, colour). */
732
1303
  syncLightHelpers() {
@@ -900,7 +1471,10 @@ var StudioWaveRenderer = class extends WaveRenderer {
900
1471
  onAfterRenderFrame() {
901
1472
  if (this.overlay && this.editing && !this.capturing && this.overlay.children.length > 0) {
902
1473
  const helpers = this.editMode === "wave" ? this.waveHelpers : this.lightHelpers;
903
- for (const h of helpers) h.scale.setScalar(Math.max(.1, this.camera.position.distanceTo(h.position) * .09));
1474
+ for (const h of helpers) {
1475
+ if (this.scaleDragStart && h === this.waveHelpers[this.selectedWave]) continue;
1476
+ h.scale.setScalar(Math.max(.1, this.camera.position.distanceTo(h.position) * .09));
1477
+ }
904
1478
  this.renderer.autoClear = false;
905
1479
  this.renderer.setRenderTarget(null);
906
1480
  this.renderer.render(this.overlay, this.camera);