@wave3d/core 0.10.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.
- package/dist/config/model.d.ts +209 -6
- package/dist/config/model.js +85 -3
- package/dist/config/model.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/presets.js +295 -0
- package/dist/presets.js.map +1 -1
- package/dist/renderer/WaveGeometry.js +21 -0
- package/dist/renderer/WaveGeometry.js.map +1 -1
- package/dist/renderer/WaveRenderer.d.ts +62 -0
- package/dist/renderer/WaveRenderer.js +349 -10
- package/dist/renderer/WaveRenderer.js.map +1 -1
- package/dist/renderer/WaveRendererGPU.js +32 -2
- package/dist/renderer/WaveRendererGPU.js.map +1 -1
- package/dist/renderer/interaction.js +12 -0
- package/dist/renderer/interaction.js.map +1 -1
- package/dist/renderer/particleField.js +26 -2
- package/dist/renderer/particleField.js.map +1 -1
- package/dist/renderer/particleFieldGPU.js +6 -0
- package/dist/renderer/particleFieldGPU.js.map +1 -1
- package/dist/renderer/shaders.js +748 -13
- package/dist/renderer/shaders.js.map +1 -1
- package/dist/renderer/tsl/dissolve.js +56 -0
- package/dist/renderer/tsl/dissolve.js.map +1 -0
- package/dist/renderer/tsl/particleMaterial.js +46 -8
- package/dist/renderer/tsl/particleMaterial.js.map +1 -1
- package/dist/renderer/tsl/uniforms.js +40 -1
- package/dist/renderer/tsl/uniforms.js.map +1 -1
- package/dist/renderer/tsl/waveMaterial.js +275 -26
- package/dist/renderer/tsl/waveMaterial.js.map +1 -1
- package/dist/renderer/tsl/waveShape.js +31 -10
- package/dist/renderer/tsl/waveShape.js.map +1 -1
- package/dist/renderer/wavePath.js +189 -0
- package/dist/renderer/wavePath.js.map +1 -0
- package/dist/shell/createWave.d.ts +23 -3
- package/dist/shell/createWave.js +5 -4
- package/dist/shell/createWave.js.map +1 -1
- package/dist/shell/probe.d.ts +28 -0
- package/dist/shell/probe.js +58 -11
- package/dist/shell/probe.js.map +1 -1
- package/dist/standalone/wave3d.standalone.js +3401 -2108
- package/dist/standalone/wave3d.standalone.webgpu.js +7372 -5848
- package/dist/standalone.d.ts +2 -2
- package/dist/standalone.js +2 -2
- package/dist/studio/StudioWaveRenderer.d.ts +115 -8
- package/dist/studio/StudioWaveRenderer.js +588 -14
- package/dist/studio/StudioWaveRenderer.js.map +1 -1
- package/dist/studio/index.d.ts +2 -2
- package/dist/studio/index.js.map +1 -1
- package/dist/studio/randomize.js +0 -1
- package/dist/studio/randomize.js.map +1 -1
- package/package.json +1 -1
- 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,
|
|
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
|
|
138
|
-
*
|
|
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 === "
|
|
143
|
-
if (mode
|
|
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
|
-
|
|
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
|
|
620
|
-
|
|
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)
|
|
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);
|