@wave3d/core 0.6.0 → 0.8.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.
@@ -4,8 +4,8 @@ import * as THREE from "three";
4
4
  * Base wave geometry — `folded()`: a flat PlaneGeometry folded into a hairpin
5
5
  * (sideways-U) cross-section, then stood up so the fold runs along the wave's length.
6
6
  *
7
- * - Each vertex gets a half-thickness `r` (per-vertex math below): tight along the
8
- * width centreline, flaring toward the long edges.
7
+ * - Each vertex gets a half-thickness `r` (per-vertex math below): tight at the middle
8
+ * of the wave's length, flaring toward both ends.
9
9
  * - The strip |x| < FOLD_X becomes a semicircular hinge; the plane's two halves bend
10
10
  * around it into parallel arms offset to +r and -r.
11
11
  * - Two −90° rotations (about X then Y) orient the U upright and down its length.
@@ -16,7 +16,26 @@ import * as THREE from "three";
16
16
  * faces only, no vertex positions move.
17
17
  *
18
18
  * All further deformation (displacement, twist, transform) happens in the vertex shader
19
- * on top of this base. UVs: u along the fold/length, v across the width.
19
+ * on top of this base.
20
+ *
21
+ * UV AXES — the canonical statement, because this is easy to get backwards and the rest of
22
+ * the codebase reasons in uv. The plane is folded along its local x, which is the COLUMN
23
+ * direction (uv.x), and the two −90° rotations land world = (plane.y, plane.z, plane.x):
24
+ *
25
+ * uv.y → the ribbon's 400-unit LENGTH (world X — the axis `displaceFrequency.x` drives)
26
+ * uv.x → the folded ~188-unit WIDTH, wrapping the hairpin cross-section (world Z)
27
+ *
28
+ * So u runs ACROSS the fold and v runs ALONG it. The welding below corroborates it twice:
29
+ * the end-caps fan across columns at rows v=0 / v=subX, and the seam joins col 0 to col
30
+ * subX down every row — a join that necessarily runs the full length. Measured on a built
31
+ * mesh, the correlation of uv.y with world X is exactly 1.0, and of uv.x with world X, 0.
32
+ * (uv.x vs world Z also reads 0 — the fold's own signature: a monotone mapping would give
33
+ * ±1, but the hairpin runs out along one arm and back along the other.)
34
+ *
35
+ * Consequences that read backwards if you assume otherwise: a `NoiseBand`'s startX/endX
36
+ * are the SHORT axis; `edgeFeather` softens the two ends, not the long edges; the palette
37
+ * texture's "edge tint" lands on the ends; `parabolaPower` bunches streaks toward the long
38
+ * edges; and the twist X/Z falloffs run lengthwise while Y runs across the width.
20
39
  */
21
40
  declare class WaveGeometry {
22
41
  readonly geometry: THREE.BufferGeometry;
@@ -11,8 +11,8 @@ const Y_AXIS = new THREE.Vector3(0, 1, 0);
11
11
  * Base wave geometry — `folded()`: a flat PlaneGeometry folded into a hairpin
12
12
  * (sideways-U) cross-section, then stood up so the fold runs along the wave's length.
13
13
  *
14
- * - Each vertex gets a half-thickness `r` (per-vertex math below): tight along the
15
- * width centreline, flaring toward the long edges.
14
+ * - Each vertex gets a half-thickness `r` (per-vertex math below): tight at the middle
15
+ * of the wave's length, flaring toward both ends.
16
16
  * - The strip |x| < FOLD_X becomes a semicircular hinge; the plane's two halves bend
17
17
  * around it into parallel arms offset to +r and -r.
18
18
  * - Two −90° rotations (about X then Y) orient the U upright and down its length.
@@ -23,7 +23,26 @@ const Y_AXIS = new THREE.Vector3(0, 1, 0);
23
23
  * faces only, no vertex positions move.
24
24
  *
25
25
  * All further deformation (displacement, twist, transform) happens in the vertex shader
26
- * on top of this base. UVs: u along the fold/length, v across the width.
26
+ * on top of this base.
27
+ *
28
+ * UV AXES — the canonical statement, because this is easy to get backwards and the rest of
29
+ * the codebase reasons in uv. The plane is folded along its local x, which is the COLUMN
30
+ * direction (uv.x), and the two −90° rotations land world = (plane.y, plane.z, plane.x):
31
+ *
32
+ * uv.y → the ribbon's 400-unit LENGTH (world X — the axis `displaceFrequency.x` drives)
33
+ * uv.x → the folded ~188-unit WIDTH, wrapping the hairpin cross-section (world Z)
34
+ *
35
+ * So u runs ACROSS the fold and v runs ALONG it. The welding below corroborates it twice:
36
+ * the end-caps fan across columns at rows v=0 / v=subX, and the seam joins col 0 to col
37
+ * subX down every row — a join that necessarily runs the full length. Measured on a built
38
+ * mesh, the correlation of uv.y with world X is exactly 1.0, and of uv.x with world X, 0.
39
+ * (uv.x vs world Z also reads 0 — the fold's own signature: a monotone mapping would give
40
+ * ±1, but the hairpin runs out along one arm and back along the other.)
41
+ *
42
+ * Consequences that read backwards if you assume otherwise: a `NoiseBand`'s startX/endX
43
+ * are the SHORT axis; `edgeFeather` softens the two ends, not the long edges; the palette
44
+ * texture's "edge tint" lands on the ends; `parabolaPower` bunches streaks toward the long
45
+ * edges; and the twist X/Z falloffs run lengthwise while Y runs across the width.
27
46
  */
28
47
  var WaveGeometry = class {
29
48
  geometry;
@@ -1 +1 @@
1
- {"version":3,"file":"WaveGeometry.js","names":[],"sources":["../../src/renderer/WaveGeometry.ts"],"sourcesContent":["import * as THREE from \"three\";\n\n/** Native plane size for folded() — keep this exact (400) so the vertex\n * shader's displace/twist frequencies (calibrated to this scale) stay faithful. */\nconst NATIVE = 400;\nconst FOLD_X = 16; // |x| < 16 is the semicircular hinge; outside it the two flat arms\nconst SHIFT = NATIVE / 4; // recentre the folded cross-section along x\n\nconst X_AXIS = new THREE.Vector3(1, 0, 0);\nconst Y_AXIS = new THREE.Vector3(0, 1, 0);\n\n/**\n * Local-Z centre of the folded ribbon's width. The fold collapses x ∈ [-NATIVE/2, NATIVE/2] onto a\n * single arm and SHIFT recentres it, which lands the width at [-100, 84] rather than symmetric\n * about 0 — so a rotation about local X through the ORIGIN would swing the ribbon's two long edges\n * to radii 100 and 84 (a visibly lopsided helix). The vertex shader's helix roll rotates about this\n * line instead, so both edges come out at equal radius.\n */\nexport const RIBBON_Z_CENTER = (SHIFT - NATIVE / 2 + (SHIFT - FOLD_X)) / 2;\n\n/**\n * Base wave geometry — `folded()`: a flat PlaneGeometry folded into a hairpin\n * (sideways-U) cross-section, then stood up so the fold runs along the wave's length.\n *\n * - Each vertex gets a half-thickness `r` (per-vertex math below): tight along the\n * width centreline, flaring toward the long edges.\n * - The strip |x| < FOLD_X becomes a semicircular hinge; the plane's two halves bend\n * around it into parallel arms offset to +r and -r.\n * - Two −90° rotations (about X then Y) orient the U upright and down its length.\n *\n * folded() leaves the U open along one side and hollow at both ends, so at oblique\n * camera angles you could see straight through it. We weld the open side and cap both\n * ends with extra triangles so the mesh is a watertight solid — welding/capping adds\n * faces only, no vertex positions move.\n *\n * All further deformation (displacement, twist, transform) happens in the vertex shader\n * on top of this base. UVs: u along the fold/length, v across the width.\n */\nexport class WaveGeometry {\n readonly geometry: THREE.BufferGeometry;\n private segments = -1;\n\n constructor(segments: number) {\n this.geometry = new THREE.BufferGeometry();\n this.resize(segments);\n }\n\n resize(segments: number): void {\n if (segments === this.segments) return;\n this.segments = segments;\n\n // subX along the fold, subY across the width (twice as dense).\n const subX = THREE.MathUtils.clamp(Math.round(segments), 48, 200);\n const subY = subX * 2;\n\n const plane = new THREE.PlaneGeometry(NATIVE, NATIVE, subX, subY);\n const pos = plane.attributes.position as THREE.BufferAttribute;\n const uv = plane.attributes.uv as THREE.BufferAttribute;\n const v = new THREE.Vector3();\n\n for (let i = 0; i < pos.count; i++) {\n v.fromBufferAttribute(pos, i);\n const uy = uv.getY(i);\n // r: cross-section half-thickness — tight (2) along the width centreline, flaring (4)\n // toward the long edges. The pow() term is a sharp parabolic bump peaking at uv.y = 0.5.\n const r = 4 - 2 * Math.pow(4 * uy * (1 - uy), 9.5);\n\n if (v.x < -FOLD_X) {\n v.z += r; // long arm, at +r\n } else if (v.x < FOLD_X) {\n // semicircular hinge: z sweeps +r → -r, x collapses to the bend\n v.z = Math.cos(THREE.MathUtils.mapLinear(v.x, -FOLD_X, FOLD_X, 0, Math.PI)) * r;\n v.x =\n Math.cos(THREE.MathUtils.mapLinear(v.x, -FOLD_X, FOLD_X, -Math.PI / 2, Math.PI / 2)) * r -\n FOLD_X;\n } else {\n v.z -= r; // folded-over arm, mirrored back at -r\n v.x = -v.x;\n }\n\n v.x += SHIFT;\n v.applyAxisAngle(X_AXIS, -Math.PI / 2);\n v.applyAxisAngle(Y_AXIS, -Math.PI / 2);\n pos.setXYZ(i, v.x, v.y, v.z);\n }\n pos.needsUpdate = true;\n\n // Seal the hairpin's OPEN side. folded() leaves the two arm tips unconnected — the\n // plane's u=0 and u=subX edges, which fold to adjacent tips at +r and -r — so at oblique\n // camera angles you can see through the U to the background. Weld those two edges with a\n // strip of triangles, closing the tube. No vertex positions move; this only adds faces\n // over the previously-open seam.\n const cols = subX + 1;\n const srcIdx = plane.getIndex();\n const merged = srcIdx ? Array.from(srcIdx.array as ArrayLike<number>) : [];\n // (a) Weld the U's side opening: the u=0 and u=subX edges fold to adjacent tips at ±r.\n for (let iy = 0; iy < subY; iy++) {\n const a = iy * cols; // (row iy, col 0) — arm-A tip\n const b = (iy + 1) * cols; // (row iy+1, col 0)\n const c = a + subX; // (row iy, col subX) — arm-B tip\n const d = b + subX; // (row iy+1, col subX)\n merged.push(a, c, b, b, c, d);\n }\n // (b) Cap the two length-ends (v=0 and v=subX rows): the folded sheet is a hollow channel\n // open at both ends, so an edge-on camera sees straight through it. Fan-triangulate each\n // end's U cross-section (apex = the col-0 tip) to close it — making the wave a closed solid.\n for (const row of [0, subY]) {\n const apex = row * cols;\n for (let ix = 1; ix < subX; ix++) merged.push(apex, row * cols + ix, row * cols + ix + 1);\n }\n plane.setIndex(merged);\n\n plane.computeVertexNormals();\n\n // Move the baked attributes onto our reusable geometry, then drop the temp.\n this.geometry.setIndex(plane.getIndex());\n this.geometry.setAttribute(\"position\", plane.getAttribute(\"position\"));\n this.geometry.setAttribute(\"uv\", plane.getAttribute(\"uv\"));\n this.geometry.setAttribute(\"normal\", plane.getAttribute(\"normal\"));\n this.geometry.computeBoundingSphere();\n plane.dispose();\n }\n\n dispose(): void {\n this.geometry.dispose();\n }\n}\n"],"mappings":";;;;AAIA,MAAM,SAAS;AACf,MAAM,SAAS;AACf,MAAM,QAAQ,SAAS;AAEvB,MAAM,SAAS,IAAI,MAAM,QAAQ,GAAG,GAAG,CAAC;AACxC,MAAM,SAAS,IAAI,MAAM,QAAQ,GAAG,GAAG,CAAC;;;;;;;;;;;;;;;;;;;AA6BxC,IAAa,eAAb,MAA0B;CACxB;CACA,WAAmB;CAEnB,YAAY,UAAkB;EAC5B,KAAK,WAAW,IAAI,MAAM,eAAe;EACzC,KAAK,OAAO,QAAQ;CACtB;CAEA,OAAO,UAAwB;EAC7B,IAAI,aAAa,KAAK,UAAU;EAChC,KAAK,WAAW;EAGhB,MAAM,OAAO,MAAM,UAAU,MAAM,KAAK,MAAM,QAAQ,GAAG,IAAI,GAAG;EAChE,MAAM,OAAO,OAAO;EAEpB,MAAM,QAAQ,IAAI,MAAM,cAAc,QAAQ,QAAQ,MAAM,IAAI;EAChE,MAAM,MAAM,MAAM,WAAW;EAC7B,MAAM,KAAK,MAAM,WAAW;EAC5B,MAAM,IAAI,IAAI,MAAM,QAAQ;EAE5B,KAAK,IAAI,IAAI,GAAG,IAAI,IAAI,OAAO,KAAK;GAClC,EAAE,oBAAoB,KAAK,CAAC;GAC5B,MAAM,KAAK,GAAG,KAAK,CAAC;GAGpB,MAAM,IAAI,IAAI,IAAI,KAAK,IAAI,IAAI,MAAM,IAAI,KAAK,GAAG;GAEjD,IAAI,EAAE,IAAI,KACR,EAAE,KAAK;QACF,IAAI,EAAE,IAAI,QAAQ;IAEvB,EAAE,IAAI,KAAK,IAAI,MAAM,UAAU,UAAU,EAAE,GAAG,KAAS,QAAQ,GAAG,KAAK,EAAE,CAAC,IAAI;IAC9E,EAAE,IACA,KAAK,IAAI,MAAM,UAAU,UAAU,EAAE,GAAG,KAAS,QAAQ,CAAC,KAAK,KAAK,GAAG,KAAK,KAAK,CAAC,CAAC,IAAI,IACvF;GACJ,OAAO;IACL,EAAE,KAAK;IACP,EAAE,IAAI,CAAC,EAAE;GACX;GAEA,EAAE,KAAK;GACP,EAAE,eAAe,QAAQ,CAAC,KAAK,KAAK,CAAC;GACrC,EAAE,eAAe,QAAQ,CAAC,KAAK,KAAK,CAAC;GACrC,IAAI,OAAO,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC;EAC7B;EACA,IAAI,cAAc;EAOlB,MAAM,OAAO,OAAO;EACpB,MAAM,SAAS,MAAM,SAAS;EAC9B,MAAM,SAAS,SAAS,MAAM,KAAK,OAAO,KAA0B,IAAI,CAAC;EAEzE,KAAK,IAAI,KAAK,GAAG,KAAK,MAAM,MAAM;GAChC,MAAM,IAAI,KAAK;GACf,MAAM,KAAK,KAAK,KAAK;GACrB,MAAM,IAAI,IAAI;GACd,MAAM,IAAI,IAAI;GACd,OAAO,KAAK,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC;EAC9B;EAIA,KAAK,MAAM,OAAO,CAAC,GAAG,IAAI,GAAG;GAC3B,MAAM,OAAO,MAAM;GACnB,KAAK,IAAI,KAAK,GAAG,KAAK,MAAM,MAAM,OAAO,KAAK,MAAM,MAAM,OAAO,IAAI,MAAM,OAAO,KAAK,CAAC;EAC1F;EACA,MAAM,SAAS,MAAM;EAErB,MAAM,qBAAqB;EAG3B,KAAK,SAAS,SAAS,MAAM,SAAS,CAAC;EACvC,KAAK,SAAS,aAAa,YAAY,MAAM,aAAa,UAAU,CAAC;EACrE,KAAK,SAAS,aAAa,MAAM,MAAM,aAAa,IAAI,CAAC;EACzD,KAAK,SAAS,aAAa,UAAU,MAAM,aAAa,QAAQ,CAAC;EACjE,KAAK,SAAS,sBAAsB;EACpC,MAAM,QAAQ;CAChB;CAEA,UAAgB;EACd,KAAK,SAAS,QAAQ;CACxB;AACF"}
1
+ {"version":3,"file":"WaveGeometry.js","names":[],"sources":["../../src/renderer/WaveGeometry.ts"],"sourcesContent":["import * as THREE from \"three\";\n\n/** Native plane size for folded() — keep this exact (400) so the vertex\n * shader's displace/twist frequencies (calibrated to this scale) stay faithful. */\nconst NATIVE = 400;\nconst FOLD_X = 16; // |x| < 16 is the semicircular hinge; outside it the two flat arms\nconst SHIFT = NATIVE / 4; // recentre the folded cross-section along x\n\nconst X_AXIS = new THREE.Vector3(1, 0, 0);\nconst Y_AXIS = new THREE.Vector3(0, 1, 0);\n\n/**\n * Local-Z centre of the folded ribbon's width. The fold collapses x ∈ [-NATIVE/2, NATIVE/2] onto a\n * single arm and SHIFT recentres it, which lands the width at [-100, 84] rather than symmetric\n * about 0 — so a rotation about local X through the ORIGIN would swing the ribbon's two long edges\n * to radii 100 and 84 (a visibly lopsided helix). The vertex shader's helix roll rotates about this\n * line instead, so both edges come out at equal radius.\n */\nexport const RIBBON_Z_CENTER = (SHIFT - NATIVE / 2 + (SHIFT - FOLD_X)) / 2;\n\n/**\n * Base wave geometry — `folded()`: a flat PlaneGeometry folded into a hairpin\n * (sideways-U) cross-section, then stood up so the fold runs along the wave's length.\n *\n * - Each vertex gets a half-thickness `r` (per-vertex math below): tight at the middle\n * of the wave's length, flaring toward both ends.\n * - The strip |x| < FOLD_X becomes a semicircular hinge; the plane's two halves bend\n * around it into parallel arms offset to +r and -r.\n * - Two −90° rotations (about X then Y) orient the U upright and down its length.\n *\n * folded() leaves the U open along one side and hollow at both ends, so at oblique\n * camera angles you could see straight through it. We weld the open side and cap both\n * ends with extra triangles so the mesh is a watertight solid — welding/capping adds\n * faces only, no vertex positions move.\n *\n * All further deformation (displacement, twist, transform) happens in the vertex shader\n * on top of this base.\n *\n * UV AXES — the canonical statement, because this is easy to get backwards and the rest of\n * the codebase reasons in uv. The plane is folded along its local x, which is the COLUMN\n * direction (uv.x), and the two −90° rotations land world = (plane.y, plane.z, plane.x):\n *\n * uv.y → the ribbon's 400-unit LENGTH (world X — the axis `displaceFrequency.x` drives)\n * uv.x → the folded ~188-unit WIDTH, wrapping the hairpin cross-section (world Z)\n *\n * So u runs ACROSS the fold and v runs ALONG it. The welding below corroborates it twice:\n * the end-caps fan across columns at rows v=0 / v=subX, and the seam joins col 0 to col\n * subX down every row — a join that necessarily runs the full length. Measured on a built\n * mesh, the correlation of uv.y with world X is exactly 1.0, and of uv.x with world X, 0.\n * (uv.x vs world Z also reads 0 — the fold's own signature: a monotone mapping would give\n * ±1, but the hairpin runs out along one arm and back along the other.)\n *\n * Consequences that read backwards if you assume otherwise: a `NoiseBand`'s startX/endX\n * are the SHORT axis; `edgeFeather` softens the two ends, not the long edges; the palette\n * texture's \"edge tint\" lands on the ends; `parabolaPower` bunches streaks toward the long\n * edges; and the twist X/Z falloffs run lengthwise while Y runs across the width.\n */\nexport class WaveGeometry {\n readonly geometry: THREE.BufferGeometry;\n private segments = -1;\n\n constructor(segments: number) {\n this.geometry = new THREE.BufferGeometry();\n this.resize(segments);\n }\n\n resize(segments: number): void {\n if (segments === this.segments) return;\n this.segments = segments;\n\n // subX across the fold (the cross-section), subY along the length (twice as dense).\n const subX = THREE.MathUtils.clamp(Math.round(segments), 48, 200);\n const subY = subX * 2;\n\n const plane = new THREE.PlaneGeometry(NATIVE, NATIVE, subX, subY);\n const pos = plane.attributes.position as THREE.BufferAttribute;\n const uv = plane.attributes.uv as THREE.BufferAttribute;\n const v = new THREE.Vector3();\n\n for (let i = 0; i < pos.count; i++) {\n v.fromBufferAttribute(pos, i);\n const uy = uv.getY(i);\n // r: cross-section half-thickness — tight (2) at the middle of the length, flaring (4)\n // toward both ends. The pow() term is a sharp parabolic bump peaking at uv.y = 0.5.\n const r = 4 - 2 * Math.pow(4 * uy * (1 - uy), 9.5);\n\n if (v.x < -FOLD_X) {\n v.z += r; // long arm, at +r\n } else if (v.x < FOLD_X) {\n // semicircular hinge: z sweeps +r → -r, x collapses to the bend\n v.z = Math.cos(THREE.MathUtils.mapLinear(v.x, -FOLD_X, FOLD_X, 0, Math.PI)) * r;\n v.x =\n Math.cos(THREE.MathUtils.mapLinear(v.x, -FOLD_X, FOLD_X, -Math.PI / 2, Math.PI / 2)) * r -\n FOLD_X;\n } else {\n v.z -= r; // folded-over arm, mirrored back at -r\n v.x = -v.x;\n }\n\n v.x += SHIFT;\n v.applyAxisAngle(X_AXIS, -Math.PI / 2);\n v.applyAxisAngle(Y_AXIS, -Math.PI / 2);\n pos.setXYZ(i, v.x, v.y, v.z);\n }\n pos.needsUpdate = true;\n\n // Seal the hairpin's OPEN side. folded() leaves the two arm tips unconnected — the\n // plane's u=0 and u=subX edges, which fold to adjacent tips at +r and -r — so at oblique\n // camera angles you can see through the U to the background. Weld those two edges with a\n // strip of triangles, closing the tube. No vertex positions move; this only adds faces\n // over the previously-open seam.\n const cols = subX + 1;\n const srcIdx = plane.getIndex();\n const merged = srcIdx ? Array.from(srcIdx.array as ArrayLike<number>) : [];\n // (a) Weld the U's side opening: the u=0 and u=subX edges fold to adjacent tips at ±r.\n for (let iy = 0; iy < subY; iy++) {\n const a = iy * cols; // (row iy, col 0) — arm-A tip\n const b = (iy + 1) * cols; // (row iy+1, col 0)\n const c = a + subX; // (row iy, col subX) — arm-B tip\n const d = b + subX; // (row iy+1, col subX)\n merged.push(a, c, b, b, c, d);\n }\n // (b) Cap the two length-ends (v=0 and v=subX rows): the folded sheet is a hollow channel\n // open at both ends, so an edge-on camera sees straight through it. Fan-triangulate each\n // end's U cross-section (apex = the col-0 tip) to close it — making the wave a closed solid.\n for (const row of [0, subY]) {\n const apex = row * cols;\n for (let ix = 1; ix < subX; ix++) merged.push(apex, row * cols + ix, row * cols + ix + 1);\n }\n plane.setIndex(merged);\n\n plane.computeVertexNormals();\n\n // Move the baked attributes onto our reusable geometry, then drop the temp.\n this.geometry.setIndex(plane.getIndex());\n this.geometry.setAttribute(\"position\", plane.getAttribute(\"position\"));\n this.geometry.setAttribute(\"uv\", plane.getAttribute(\"uv\"));\n this.geometry.setAttribute(\"normal\", plane.getAttribute(\"normal\"));\n this.geometry.computeBoundingSphere();\n plane.dispose();\n }\n\n dispose(): void {\n this.geometry.dispose();\n }\n}\n"],"mappings":";;;;AAIA,MAAM,SAAS;AACf,MAAM,SAAS;AACf,MAAM,QAAQ,SAAS;AAEvB,MAAM,SAAS,IAAI,MAAM,QAAQ,GAAG,GAAG,CAAC;AACxC,MAAM,SAAS,IAAI,MAAM,QAAQ,GAAG,GAAG,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgDxC,IAAa,eAAb,MAA0B;CACxB;CACA,WAAmB;CAEnB,YAAY,UAAkB;EAC5B,KAAK,WAAW,IAAI,MAAM,eAAe;EACzC,KAAK,OAAO,QAAQ;CACtB;CAEA,OAAO,UAAwB;EAC7B,IAAI,aAAa,KAAK,UAAU;EAChC,KAAK,WAAW;EAGhB,MAAM,OAAO,MAAM,UAAU,MAAM,KAAK,MAAM,QAAQ,GAAG,IAAI,GAAG;EAChE,MAAM,OAAO,OAAO;EAEpB,MAAM,QAAQ,IAAI,MAAM,cAAc,QAAQ,QAAQ,MAAM,IAAI;EAChE,MAAM,MAAM,MAAM,WAAW;EAC7B,MAAM,KAAK,MAAM,WAAW;EAC5B,MAAM,IAAI,IAAI,MAAM,QAAQ;EAE5B,KAAK,IAAI,IAAI,GAAG,IAAI,IAAI,OAAO,KAAK;GAClC,EAAE,oBAAoB,KAAK,CAAC;GAC5B,MAAM,KAAK,GAAG,KAAK,CAAC;GAGpB,MAAM,IAAI,IAAI,IAAI,KAAK,IAAI,IAAI,MAAM,IAAI,KAAK,GAAG;GAEjD,IAAI,EAAE,IAAI,KACR,EAAE,KAAK;QACF,IAAI,EAAE,IAAI,QAAQ;IAEvB,EAAE,IAAI,KAAK,IAAI,MAAM,UAAU,UAAU,EAAE,GAAG,KAAS,QAAQ,GAAG,KAAK,EAAE,CAAC,IAAI;IAC9E,EAAE,IACA,KAAK,IAAI,MAAM,UAAU,UAAU,EAAE,GAAG,KAAS,QAAQ,CAAC,KAAK,KAAK,GAAG,KAAK,KAAK,CAAC,CAAC,IAAI,IACvF;GACJ,OAAO;IACL,EAAE,KAAK;IACP,EAAE,IAAI,CAAC,EAAE;GACX;GAEA,EAAE,KAAK;GACP,EAAE,eAAe,QAAQ,CAAC,KAAK,KAAK,CAAC;GACrC,EAAE,eAAe,QAAQ,CAAC,KAAK,KAAK,CAAC;GACrC,IAAI,OAAO,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC;EAC7B;EACA,IAAI,cAAc;EAOlB,MAAM,OAAO,OAAO;EACpB,MAAM,SAAS,MAAM,SAAS;EAC9B,MAAM,SAAS,SAAS,MAAM,KAAK,OAAO,KAA0B,IAAI,CAAC;EAEzE,KAAK,IAAI,KAAK,GAAG,KAAK,MAAM,MAAM;GAChC,MAAM,IAAI,KAAK;GACf,MAAM,KAAK,KAAK,KAAK;GACrB,MAAM,IAAI,IAAI;GACd,MAAM,IAAI,IAAI;GACd,OAAO,KAAK,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC;EAC9B;EAIA,KAAK,MAAM,OAAO,CAAC,GAAG,IAAI,GAAG;GAC3B,MAAM,OAAO,MAAM;GACnB,KAAK,IAAI,KAAK,GAAG,KAAK,MAAM,MAAM,OAAO,KAAK,MAAM,MAAM,OAAO,IAAI,MAAM,OAAO,KAAK,CAAC;EAC1F;EACA,MAAM,SAAS,MAAM;EAErB,MAAM,qBAAqB;EAG3B,KAAK,SAAS,SAAS,MAAM,SAAS,CAAC;EACvC,KAAK,SAAS,aAAa,YAAY,MAAM,aAAa,UAAU,CAAC;EACrE,KAAK,SAAS,aAAa,MAAM,MAAM,aAAa,IAAI,CAAC;EACzD,KAAK,SAAS,aAAa,UAAU,MAAM,aAAa,QAAQ,CAAC;EACjE,KAAK,SAAS,sBAAsB;EACpC,MAAM,QAAQ;CAChB;CAEA,UAAgB;EACd,KAAK,SAAS,QAAQ;CACxB;AACF"}
@@ -1,5 +1,6 @@
1
1
  import { CameraFit, StudioConfig, WaveConfig } from "../config/model.js";
2
2
  import { WaveGeometry } from "./WaveGeometry.js";
3
+ import { ParticleField } from "./particleField.js";
3
4
  import { InteractionController } from "./interaction.js";
4
5
  import * as THREE from "three";
5
6
  //#region src/renderer/WaveRenderer.d.ts
@@ -66,6 +67,9 @@ type Wave = {
66
67
  geometry: WaveGeometry;
67
68
  /** This wave's own 2D palette texture + optional video. */
68
69
  palette: WavePalette;
70
+ /** This wave's own particle / dust field — created when its `particles.count` first goes >0,
71
+ * disposed at 0 / absent (the WavePalette lifecycle pattern). Undefined = no dust for this wave. */
72
+ particleField?: ParticleField;
69
73
  };
70
74
  /** Convert an sRGB hex string to a linear-space RGB vector (three's ColorManagement does the
71
75
  * sRGB→linear conversion on parse). Exported for the studio subclass's live light-uniform push. */
@@ -90,6 +94,9 @@ declare class WaveRenderer {
90
94
  private heatmapPass?;
91
95
  private paperTexturePass?;
92
96
  private halftoneCmykPass?;
97
+ private readonly fxCenter;
98
+ private readonly fxRight;
99
+ private readonly fxUp;
93
100
  protected readonly container: HTMLElement;
94
101
  private readonly respectReducedMotion;
95
102
  private readonly skipIntroRamp;
@@ -260,6 +267,21 @@ declare class WaveRenderer {
260
267
  private updateTime;
261
268
  /** Render exactly one frame at the current time. */
262
269
  renderOnce(): void;
270
+ /** Insert / sync / remove EACH wave's particle field — mirrors applyBloom's lazy lifecycle, per wave.
271
+ * An absent block or count 0 means no THREE.Points for that wave (byte-identical). The seeded buffers
272
+ * rebuild only when the (count, seed, edgeBias, bias) signature changes; the rest are live uniforms.
273
+ * configure() (the wave-shape binding) runs later in updateSceneFx, once transforms are current. */
274
+ private applyParticles;
275
+ /** Per-frame placement for every wave's particle field: derive the camera's screen basis + each
276
+ * wave's world centre (drift radiates from there) and bind that wave's live shape (#defines +
277
+ * uniforms + matrixWorld) so the dust rides the SAME deform as the ribbon. Runs after refresh()'s
278
+ * per-wave loop so the wave transforms/uniforms are current. No allocation; a no-op when off. */
279
+ private updateSceneFx;
280
+ /** The subset of waveDefines() the particle program shares: the shape gates (what waveShape reads,
281
+ * so the dust matches its wave's deform) plus the pointer gates (what pointerField reads, so the
282
+ * dust reacts to the same cursor). Every one of these is derived from CONFIG alone — live input
283
+ * must never reach here, or each frame would flip the define set and recompile the point program. */
284
+ private shapeDefines;
263
285
  /** Create/dispose the interaction controller as config toggles interaction on/off. Called from
264
286
  * refresh(); the compiled define set (POINTER_FX etc.) is handled separately by waveDefines(). */
265
287
  private syncInteraction;
@@ -2,6 +2,7 @@ import { ensureStudioConfig } from "../config/model.js";
2
2
  import { WaveGeometry } from "./WaveGeometry.js";
3
3
  import { ditherFragmentShader, fragmentShader, halftoneCmykFragmentShader, halftoneFragmentShader, heatmapFragmentShader, innerLightFragmentShader, lineFragmentShader, paperTextureFragmentShader, postFragmentShader, postVertexShader, vertexShader } from "./shaders.js";
4
4
  import { InteractionController, SCENE_APPLIERS, WAVE_APPLIERS, anyPointerFxActive, interactionActive, wavePointerFxActive, waveRipplesActive } from "./interaction.js";
5
+ import { ParticleField } from "./particleField.js";
5
6
  import { PALETTE_MAPS, buildBackgroundGradientCanvas, buildBackgroundImageCanvas, buildBackgroundMeshCanvas, buildPaletteTexture, canvasToTexture, configurePaletteTexture, drawBackgroundMediaFrame, loadPaletteImage, paletteMapCanvas, paletteSignature } from "./palette.js";
6
7
  import { buildHeroPaletteCanvas, buildHeroPaletteTexture } from "./heroPalette.js";
7
8
  import * as THREE from "three";
@@ -186,6 +187,9 @@ var WaveRenderer = class {
186
187
  heatmapPass;
187
188
  paperTexturePass;
188
189
  halftoneCmykPass;
190
+ fxCenter = new THREE.Vector3();
191
+ fxRight = new THREE.Vector3();
192
+ fxUp = new THREE.Vector3();
189
193
  container;
190
194
  respectReducedMotion;
191
195
  skipIntroRamp;
@@ -411,6 +415,11 @@ var WaveRenderer = class {
411
415
  uHelixRadius: { value: 0 },
412
416
  uHelixRoll: { value: 0 },
413
417
  uHelixPhase: { value: 0 },
418
+ uRadialAmount: { value: 0 },
419
+ uRadialArc: { value: 160 },
420
+ uRadialSpread: { value: 1 },
421
+ uRadialRadius: { value: 40 },
422
+ uRadialCenter: { value: 0 },
414
423
  uRungAmount: { value: 0 },
415
424
  uRungThickness: { value: 1 },
416
425
  uPointer: { value: new THREE.Vector2(0, 0) },
@@ -444,6 +453,7 @@ var WaveRenderer = class {
444
453
  if ((sc?.edgeFeather ?? .1) !== .1) defines.EDGE_FEATHER = "";
445
454
  const bindsHelix = sc?.interaction?.bindings?.some((b) => b.target.startsWith("helix")) ?? false;
446
455
  if ((sc?.helixRadius ?? 0) !== 0 || (sc?.helixRoll ?? 0) !== 0 || bindsHelix) defines.HELIX = "";
456
+ if ((sc?.radialAmount ?? 0) !== 0) defines.RADIAL = "";
447
457
  if (sc?.theme === "wireframe" && (sc.rungAmount ?? 0) > 0) defines.RUNGS = "";
448
458
  if (sc && wavePointerFxActive(this.config, sc)) {
449
459
  defines.POINTER_FX = "";
@@ -505,6 +515,10 @@ var WaveRenderer = class {
505
515
  s.material.dispose();
506
516
  s.geometry.dispose();
507
517
  s.palette.dispose();
518
+ if (s.particleField) {
519
+ this.scene.remove(s.particleField.points);
520
+ s.particleField.dispose();
521
+ }
508
522
  }
509
523
  this.waves = [];
510
524
  }
@@ -523,6 +537,10 @@ var WaveRenderer = class {
523
537
  s.material.dispose();
524
538
  s.geometry.dispose();
525
539
  s.palette.dispose();
540
+ if (s.particleField) {
541
+ this.scene.remove(s.particleField.points);
542
+ s.particleField.dispose();
543
+ }
526
544
  }
527
545
  while (this.waves.length < target) this.addWave();
528
546
  const segments = this.segments;
@@ -536,6 +554,7 @@ var WaveRenderer = class {
536
554
  refresh() {
537
555
  this.applyBackground();
538
556
  this.applyPost();
557
+ this.applyParticles();
539
558
  this.syncInteraction();
540
559
  if (!this.isCameraExternallyDriven()) {
541
560
  const p = this.config.cameraPosition;
@@ -658,6 +677,11 @@ var WaveRenderer = class {
658
677
  u.uHelixRadius.value = sc.helixRadius ?? 0;
659
678
  u.uHelixRoll.value = sc.helixRoll ?? 0;
660
679
  u.uHelixPhase.value = sc.helixPhase ?? 0;
680
+ u.uRadialAmount.value = sc.radialAmount ?? 0;
681
+ u.uRadialArc.value = sc.radialArc ?? 160;
682
+ u.uRadialSpread.value = sc.radialSpread ?? 1;
683
+ u.uRadialRadius.value = sc.radialRadius ?? 40;
684
+ u.uRadialCenter.value = sc.radialCenter ?? 0;
661
685
  wave.mesh.scale.set(sc.scale.x, sc.scale.y, sc.scale.z);
662
686
  wave.mesh.rotation.set(THREE.MathUtils.degToRad(sc.rotation.x), THREE.MathUtils.degToRad(sc.rotation.y), THREE.MathUtils.degToRad(sc.rotation.z));
663
687
  wave.mesh.position.set(sc.position.x, sc.position.y, sc.position.z);
@@ -1308,16 +1332,95 @@ var WaveRenderer = class {
1308
1332
  }
1309
1333
  }
1310
1334
  this.postPass.uniforms.uTime.value = t;
1335
+ for (const w of this.waves) w.particleField?.setTime(t);
1311
1336
  }
1312
1337
  /** Render exactly one frame at the current time. */
1313
1338
  renderOnce() {
1314
1339
  this.updateBackgroundVideoFrame();
1315
1340
  this.updateTime();
1316
1341
  this.applyInteraction();
1342
+ this.updateSceneFx();
1317
1343
  this.updateClipPlanes();
1318
1344
  this.composer.render();
1319
1345
  this.onAfterRenderFrame();
1320
1346
  }
1347
+ /** Insert / sync / remove EACH wave's particle field — mirrors applyBloom's lazy lifecycle, per wave.
1348
+ * An absent block or count 0 means no THREE.Points for that wave (byte-identical). The seeded buffers
1349
+ * rebuild only when the (count, seed, edgeBias, bias) signature changes; the rest are live uniforms.
1350
+ * configure() (the wave-shape binding) runs later in updateSceneFx, once transforms are current. */
1351
+ applyParticles() {
1352
+ const loop = this.config.loopSeconds ?? 0;
1353
+ this.waves.forEach((wave, i) => {
1354
+ const cfg = (this.config.waves[i] ?? this.config.waves[this.config.waves.length - 1])?.particles;
1355
+ if (cfg && cfg.count > 0) {
1356
+ if (!wave.particleField) {
1357
+ wave.particleField = new ParticleField(() => {
1358
+ if (!this.running) this.renderOnce();
1359
+ });
1360
+ this.scene.add(wave.particleField.points);
1361
+ }
1362
+ wave.particleField.sync(cfg, loop);
1363
+ } else if (wave.particleField) {
1364
+ this.scene.remove(wave.particleField.points);
1365
+ wave.particleField.dispose();
1366
+ wave.particleField = void 0;
1367
+ }
1368
+ });
1369
+ }
1370
+ /** Per-frame placement for every wave's particle field: derive the camera's screen basis + each
1371
+ * wave's world centre (drift radiates from there) and bind that wave's live shape (#defines +
1372
+ * uniforms + matrixWorld) so the dust rides the SAME deform as the ribbon. Runs after refresh()'s
1373
+ * per-wave loop so the wave transforms/uniforms are current. No allocation; a no-op when off. */
1374
+ updateSceneFx() {
1375
+ let anyField = false;
1376
+ for (const w of this.waves) if (w.particleField) {
1377
+ anyField = true;
1378
+ break;
1379
+ }
1380
+ if (!anyField) return;
1381
+ this.camera.updateMatrixWorld();
1382
+ this.fxRight.setFromMatrixColumn(this.camera.matrixWorld, 0).normalize();
1383
+ this.fxUp.setFromMatrixColumn(this.camera.matrixWorld, 1).normalize();
1384
+ const pr = this.renderer.getPixelRatio();
1385
+ this.waves.forEach((wave, i) => {
1386
+ const field = wave.particleField;
1387
+ if (!field) return;
1388
+ const sc = this.config.waves[i] ?? this.config.waves[this.config.waves.length - 1];
1389
+ wave.mesh.updateWorldMatrix(true, false);
1390
+ wave.mesh.getWorldPosition(this.fxCenter);
1391
+ const frame = {
1392
+ center: this.fxCenter,
1393
+ right: this.fxRight,
1394
+ up: this.fxUp
1395
+ };
1396
+ field.frame(frame, pr);
1397
+ field.configure({
1398
+ defines: this.shapeDefines(sc),
1399
+ uniforms: wave.material.uniforms,
1400
+ matrixWorld: wave.mesh.matrixWorld,
1401
+ speed: sc.speed,
1402
+ seed: sc.seed
1403
+ });
1404
+ });
1405
+ }
1406
+ /** The subset of waveDefines() the particle program shares: the shape gates (what waveShape reads,
1407
+ * so the dust matches its wave's deform) plus the pointer gates (what pointerField reads, so the
1408
+ * dust reacts to the same cursor). Every one of these is derived from CONFIG alone — live input
1409
+ * must never reach here, or each frame would flip the define set and recompile the point program. */
1410
+ shapeDefines(sc) {
1411
+ const all = this.waveDefines(sc);
1412
+ const out = {};
1413
+ for (const k of [
1414
+ "LOOP_MOTION",
1415
+ "DETAIL_OCTAVE",
1416
+ "HELIX",
1417
+ "TWIST_MOTION",
1418
+ "RADIAL",
1419
+ "POINTER_FX",
1420
+ "POINTER_RIPPLES"
1421
+ ]) if (k in all) out[k] = "";
1422
+ return out;
1423
+ }
1321
1424
  /** Create/dispose the interaction controller as config toggles interaction on/off. Called from
1322
1425
  * refresh(); the compiled define set (POINTER_FX etc.) is handled separately by waveDefines(). */
1323
1426
  syncInteraction() {
@@ -1535,7 +1638,9 @@ var WaveRenderer = class {
1535
1638
  const h = sc.interaction?.hover;
1536
1639
  pointerDisp = (h?.agitate ?? 0) + Math.abs(h?.push ?? 0) + (h?.wake ?? 0) + (sc.interaction?.press?.ripple ?? 0);
1537
1640
  }
1538
- const radius = (bs.center.length() + bs.radius + disp + pointerDisp) * mesh.matrixWorld.getMaxScaleOnAxis() * 1.2;
1641
+ const localRadius = bs.center.length() + bs.radius + disp + pointerDisp;
1642
+ const drift = wave.particleField ? Math.abs(sc.particles?.drift ?? 0) : 0;
1643
+ const radius = localRadius * mesh.matrixWorld.getMaxScaleOnAxis() * 1.2 + drift;
1539
1644
  this.clipBox.expandByPoint(this.clipTmpB.copy(center).addScalar(radius));
1540
1645
  this.clipBox.expandByPoint(this.clipTmpB.copy(center).addScalar(-radius));
1541
1646
  }
@@ -1633,6 +1738,7 @@ var WaveRenderer = class {
1633
1738
  s.material.dispose();
1634
1739
  s.geometry.dispose();
1635
1740
  s.palette.dispose();
1741
+ s.particleField?.dispose();
1636
1742
  }
1637
1743
  this.bloomPass?.dispose();
1638
1744
  this.ditherPass?.dispose();