@3driseai/3drise-viewer 0.1.19 → 0.1.21

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 (33) hide show
  1. package/dist/components/CreatedObject.d.ts.map +1 -1
  2. package/dist/components/CreatedObject.js +18 -14
  3. package/dist/components/ModelMaterialPassContext.d.ts +7 -0
  4. package/dist/components/ModelMaterialPassContext.d.ts.map +1 -0
  5. package/dist/components/ModelMaterialPassContext.js +7 -0
  6. package/dist/components/ModelPrimitive.d.ts.map +1 -1
  7. package/dist/components/ModelPrimitive.js +7 -1
  8. package/dist/components/ParticlesGenerator.d.ts.map +1 -1
  9. package/dist/components/ParticlesGenerator.js +6 -0
  10. package/dist/components/particles/ChladniSandParticles.d.ts +15 -0
  11. package/dist/components/particles/ChladniSandParticles.d.ts.map +1 -0
  12. package/dist/components/particles/ChladniSandParticles.js +912 -0
  13. package/dist/components/particles/FaradayPowderParticles.d.ts +15 -0
  14. package/dist/components/particles/FaradayPowderParticles.d.ts.map +1 -0
  15. package/dist/components/particles/FaradayPowderParticles.js +783 -0
  16. package/dist/components/particles/NeonRainParticles.d.ts +15 -0
  17. package/dist/components/particles/NeonRainParticles.d.ts.map +1 -0
  18. package/dist/components/particles/NeonRainParticles.js +716 -0
  19. package/dist/components/particles/index.d.ts +6 -0
  20. package/dist/components/particles/index.d.ts.map +1 -1
  21. package/dist/components/particles/index.js +3 -0
  22. package/dist/constants.d.ts +1 -1
  23. package/dist/constants.d.ts.map +1 -1
  24. package/dist/constants.js +1 -1
  25. package/dist/hooks/index.d.ts +1 -0
  26. package/dist/hooks/index.d.ts.map +1 -1
  27. package/dist/hooks/index.js +1 -0
  28. package/dist/hooks/useMaterialApplication.d.ts.map +1 -1
  29. package/dist/hooks/useMaterialApplication.js +1 -0
  30. package/dist/hooks/useNodeOverrides.d.ts +13 -0
  31. package/dist/hooks/useNodeOverrides.d.ts.map +1 -0
  32. package/dist/hooks/useNodeOverrides.js +254 -0
  33. package/package.json +2 -2
@@ -0,0 +1,783 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
3
+ import * as THREE from 'three';
4
+ import { useFrame } from '@react-three/fiber';
5
+ import { GENERATIVE_PARTICLES_ANIMATABLE } from '@3driseai/3drise-core';
6
+ import { applyConfigKeyframeAnimations } from '../../utils/configAnimations.js';
7
+ import { createPointerUniforms, stepPointerField, usePointerField } from '../../utils/pointerField.js';
8
+ import { createTrailUniforms, applyTrailConfig, createCpuTrail } from '../../utils/particleTrails.js';
9
+ import { useObjectReady } from '../../hooks/useObjectReady.js';
10
+ import { useConfigLiveHandle } from '../../hooks/useConfigLive.js';
11
+ /**
12
+ * Faraday Powder — Glowing powder on an invisible vibrating plate: acoustic streaming gathers it into swirling heaps at the antinodes, hotter where the plate moves most; the cursor bows, touches or taps the plate. Use for abstract, sound and fire-like scenes.
13
+ */
14
+ // Faraday Powder — fine powder on an invisible vibrating plate (Faraday 1831). Particles only.
15
+ // Movement: the same centre-clamped square plate as Chladni Sand: Chladni mode pairs
16
+ // phi = cos(m pi u) cos(n pi v) +/- cos(n pi u) cos(m pi v), each ringing with a Lorentzian
17
+ // resonance while the drive frequency glides through them. Powder too light to be thrown
18
+ // ballistically does the opposite of sand: acoustic streaming in the air above the plate
19
+ // carries it up the gradient of w^2 into the antinodes, where it gathers in little heaps
20
+ // that hover (height ~ w^2) and turn, neighbouring heaps in opposite senses (the streaming
21
+ // cells alternate with the sign of w). When the drive leaves a resonance the heaps settle
22
+ // and wait; the next figure pulls them somewhere else.
23
+ // Colour: brightness and colour from the local vibration energy A^2 w^2 the powder sits in:
24
+ // resting dust is a dim ember of the base colour, dust churning in a strong antinode
25
+ // heats toward the glow colour. Additive soft puffs, so dense heaps bloom.
26
+ // Mouse (pointerMode; the cursor lives on the plate plane):
27
+ // bow — a violin bow on the nearest point of the edge: modes with an antinode there are
28
+ // favoured and bowing speed pumps energy in; rosin dust puffs off the edge.
29
+ // finger — a fingertip touching the plate forces a node there: modes that move under it are
30
+ // damped, so the heaps migrate to a figure with a nodal line through the finger.
31
+ // tap — taps along the cursor path: a bending wave rings outward and blows the powder up in
32
+ // an expanding ring; the broadband ring-down scatters the heaps for a moment.
33
+ // Trails (trailMode): streak = long exposure, comet = swirl threads, echo = smoke haze.
34
+ //
35
+ // Generated from the approved lab module (particle lab, batch 3). Sim units = object-local / size.
36
+ // ---------------------------------------------------------------------------------------------
37
+ // Engine features
38
+ // ---------------------------------------------------------------------------------------------
39
+ const QUALITY_SCALE = { low: 0.5, medium: 1, high: 1.6 };
40
+ /** mulberry32 — seeded PRNG, returns a function giving floats in [0,1). */
41
+ function mulberry32(seed) {
42
+ let a = seed >>> 0;
43
+ return function () {
44
+ a = (a + 0x6d2b79f5) >>> 0;
45
+ let t = a;
46
+ t = Math.imul(t ^ (t >>> 15), t | 1);
47
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
48
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
49
+ };
50
+ }
51
+ /** Standard normal from a uniform PRNG (Box–Muller, one value per call). */
52
+ function gauss(rng) {
53
+ const u = Math.max(rng(), 1e-9);
54
+ const v = rng();
55
+ return Math.sqrt(-2 * Math.log(u)) * Math.cos(6.283185307 * v);
56
+ }
57
+ /** Viewport-height hook so point sizes are pixel-correct at any canvas size. */
58
+ function attachViewH(obj, u) {
59
+ const scratch = new THREE.Vector2();
60
+ obj.onBeforeRender = (renderer) => {
61
+ renderer.getDrawingBufferSize(scratch);
62
+ u.uViewH.value = scratch.y;
63
+ };
64
+ }
65
+ function colorUniform(hex) {
66
+ return { value: new THREE.Color(hex) };
67
+ }
68
+ function qualityScale(q) { return QUALITY_SCALE[q] || 1; }
69
+ function seedMix(base, seed) {
70
+ let h = (base ^ Math.imul((Math.round(seed) | 0) + 0x9e37, 0x85ebca6b)) >>> 0;
71
+ h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35) >>> 0;
72
+ return (h ^ (h >>> 16)) >>> 0;
73
+ }
74
+ function createFixedStep(H, maxSteps) {
75
+ let acc = 0;
76
+ return {
77
+ H,
78
+ run(d, step) {
79
+ acc += Math.max(d, 0);
80
+ let k = 0;
81
+ while (acc >= H && k < maxSteps) {
82
+ step(H);
83
+ acc -= H;
84
+ k++;
85
+ }
86
+ if (k === maxSteps)
87
+ acc = Math.min(acc, H);
88
+ return acc / H;
89
+ },
90
+ reset() { acc = 0; },
91
+ };
92
+ }
93
+ function createPickProxy(center, radius) {
94
+ const geo = new THREE.SphereGeometry(1, 16, 10);
95
+ const mat = new THREE.MeshBasicMaterial({ visible: false });
96
+ const mesh = new THREE.Mesh(geo, mat);
97
+ mesh.name = 'pickProxy';
98
+ mesh.position.set(center[0], center[1], center[2]);
99
+ mesh.scale.setScalar(radius);
100
+ return { mesh, dispose() { geo.dispose(); mat.dispose(); } };
101
+ }
102
+ const _frustum = new THREE.Frustum();
103
+ const _pm = new THREE.Matrix4();
104
+ const _sphere = new THREE.Sphere();
105
+ function inView(object, camera, center, radius, size) {
106
+ if (!camera || !camera.projectionMatrix)
107
+ return true;
108
+ object.updateWorldMatrix(true, false);
109
+ _sphere.center.set(center[0], center[1], center[2]).multiplyScalar(size).applyMatrix4(object.matrixWorld);
110
+ _sphere.radius = radius * size * object.matrixWorld.getMaxScaleOnAxis();
111
+ _pm.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
112
+ _frustum.setFromProjectionMatrix(_pm);
113
+ return _frustum.intersectsSphere(_sphere);
114
+ }
115
+ const POINTER_MODES = ['bow', 'finger', 'tap'];
116
+ const HX = 1; // half size of the plate (sim units)
117
+ const NG = 97; // vibration grid (odd -> a sample at the clamped centre)
118
+ const NMAX = 10; // highest mode index
119
+ const H_STEP = 1 / 60;
120
+ const GAIN = 1.7; // Gamma per unit of normalised displacement at amplitude 1
121
+ const SWEEP_T = 46; // seconds per sweep cycle
122
+ const RING_UP = 0.45; // mode amplitude time constant (the plate rings up / down)
123
+ const MAX_TAPS = 4;
124
+ const NPUFF = 320;
125
+ const BOUNDS = { center: [0, 0, 0], radius: 1.55 };
126
+ const TRAIL_GAIN = { streak: 1.6, comet: 1.3, echo: 4 };
127
+ function createUniforms() {
128
+ return {
129
+ uTime: { value: 0 },
130
+ uSize: { value: 1 },
131
+ uViewH: { value: 800 },
132
+ uIntensity: { value: 1 },
133
+ uSpeed: { value: 1 },
134
+ uOpacity: { value: 0.9 },
135
+ uColor: colorUniform('#ffffff'),
136
+ uParticleCount: { value: 0 },
137
+ uFrequency: { value: 5 },
138
+ uSweep: { value: 0.3 },
139
+ uDamping: { value: 0.05 },
140
+ uAmplitude: { value: 1 },
141
+ uGlowColor: colorUniform('#ffffff'),
142
+ uHover: { value: 1 },
143
+ uSwirl: { value: 1 },
144
+ uPuffSize: { value: 1 },
145
+ uPointerMode: { value: 0 },
146
+ uPointerStrength: { value: 1 },
147
+ uPointerRadius: { value: 1 },
148
+ uPointerColor: colorUniform('#ffffff'),
149
+ uTrailGain: { value: 1 },
150
+ ...createPointerUniforms(),
151
+ ...createTrailUniforms(),
152
+ };
153
+ }
154
+ function applyConfig(u, c, size) {
155
+ u.uSize.value = size;
156
+ u.uIntensity.value = c.intensity;
157
+ u.uSpeed.value = c.speed;
158
+ u.uOpacity.value = c.opacity;
159
+ u.uColor.value.set(c.color);
160
+ u.uParticleCount.value = c.particleCount;
161
+ u.uFrequency.value = c.frequency;
162
+ u.uSweep.value = c.sweep;
163
+ u.uDamping.value = c.damping;
164
+ u.uAmplitude.value = c.amplitude;
165
+ u.uGlowColor.value.set(c.glowColor);
166
+ u.uHover.value = c.hover;
167
+ u.uSwirl.value = c.swirl;
168
+ u.uPuffSize.value = c.puffSize;
169
+ u.uPointerMode.value = POINTER_MODES.indexOf(c.pointerMode); // -1 = 'none' (off)
170
+ u.uPointerStrength.value = c.pointerStrength;
171
+ u.uPointerRadius.value = c.pointerRadius;
172
+ u.uPointerColor.value.set(c.pointerColor);
173
+ applyTrailConfig(u, c);
174
+ }
175
+ const BASE_U = 'uniform float uSize, uViewH, uIntensity, uOpacity;';
176
+ // energy ramp: dim ember of the base colour -> glow colour
177
+ const HEAT_GLSL = /* glsl */ `
178
+ uniform vec3 uColor, uGlowColor;
179
+ vec3 heat(float e) {
180
+ float t = smoothstep(0.0, 1.6, e);
181
+ return mix(uColor * 0.55, mix(uColor, uGlowColor, smoothstep(0.4, 1.0, t)), smoothstep(0.0, 0.45, t)) * (0.55 + 0.8 * t);
182
+ }
183
+ `;
184
+ const powderVertex = /* glsl */ `
185
+ ${BASE_U}
186
+ ${HEAT_GLSL}
187
+ uniform float uPuffSize;
188
+ attribute float aE; // local vibration energy A^2 w^2 (smoothed)
189
+ attribute float aSeed;
190
+ varying vec3 vCol;
191
+ varying float vA;
192
+ void main() {
193
+ vec4 mv = modelViewMatrix * vec4(position * uSize, 1.0);
194
+ vCol = heat(aE) * uIntensity;
195
+ vA = uOpacity * 0.55;
196
+ float px = uPuffSize * uSize * uViewH * 0.011 * (0.7 + 0.6 * aSeed) / -mv.z;
197
+ gl_PointSize = clamp(px, 1.0, 32.0);
198
+ gl_Position = projectionMatrix * mv;
199
+ }
200
+ `;
201
+ const powderFragment = /* glsl */ `
202
+ varying vec3 vCol;
203
+ varying float vA;
204
+ void main() {
205
+ vec2 d = gl_PointCoord * 2.0 - 1.0;
206
+ float r2 = dot(d, d);
207
+ if (r2 > 1.0) discard;
208
+ float a = exp(-r2 * 3.0) * (1.0 - r2) * vA;
209
+ if (a < 0.004) discard;
210
+ gl_FragColor = vec4(vCol, a);
211
+ }
212
+ `;
213
+ // rosin dust off the bowed edge
214
+ const puffVertex = /* glsl */ `
215
+ ${BASE_U}
216
+ attribute float aAge;
217
+ varying float vA;
218
+ void main() {
219
+ vec4 mv = modelViewMatrix * vec4(position * uSize, 1.0);
220
+ vA = aAge < 0.0 ? 0.0 : exp(-aAge / 0.5) * smoothstep(0.0, 0.05, aAge) * uOpacity;
221
+ gl_PointSize = clamp(uSize * uViewH * 0.009 * (1.0 + 2.0 * max(aAge, 0.0)) / -mv.z, 1.0, 30.0) * step(0.003, vA);
222
+ gl_Position = projectionMatrix * mv;
223
+ }
224
+ `;
225
+ const puffFragment = /* glsl */ `
226
+ uniform vec3 uPointerColor;
227
+ uniform float uIntensity;
228
+ varying float vA;
229
+ void main() {
230
+ vec2 d = gl_PointCoord * 2.0 - 1.0;
231
+ float r2 = dot(d, d);
232
+ if (r2 > 1.0) discard;
233
+ float a = exp(-r2 * 3.5) * (1.0 - r2) * vA * 0.6;
234
+ if (a < 0.004) discard;
235
+ gl_FragColor = vec4(uPointerColor * uIntensity, a);
236
+ }
237
+ `;
238
+ function build(u, c, size) {
239
+ const q = qualityScale(c.quality);
240
+ const n = Math.max(500, Math.round(c.particleCount * q));
241
+ const rng = mulberry32(seedMix(0xfa2d, c.seed));
242
+ // ---- modes: Chladni pairs with split +/- frequencies and small seeded imperfections ----
243
+ const modes = [];
244
+ for (let m = 0; m <= NMAX; m++)
245
+ for (let nn = m; nn <= NMAX; nn++) {
246
+ for (const s of m === nn ? [1] : [1, -1]) {
247
+ const f0 = Math.sqrt(m * m + nn * nn);
248
+ if (f0 < 1.9)
249
+ continue;
250
+ modes.push({ m, n: nn, s, f: f0 * (1 + (m === nn ? 0 : s * 0.035)) * (1 + 0.012 * gauss(rng)) });
251
+ }
252
+ }
253
+ const K = modes.length;
254
+ // cosine tables on the grid, and each mode's RMS so every shape has unit RMS
255
+ const C = new Float32Array((NMAX + 1) * NG);
256
+ for (let m = 0; m <= NMAX; m++)
257
+ for (let i = 0; i < NG; i++)
258
+ C[m * NG + i] = Math.cos((m * Math.PI * i) / (NG - 1));
259
+ const norm = new Float32Array(K);
260
+ for (let k = 0; k < K; k++) {
261
+ const { m, n: nn, s } = modes[k];
262
+ let acc = 0;
263
+ for (let j = 0; j < NG; j++)
264
+ for (let i = 0; i < NG; i++) {
265
+ const v = C[m * NG + i] * C[nn * NG + j] + s * C[nn * NG + i] * C[m * NG + j];
266
+ acc += v * v;
267
+ }
268
+ norm[k] = 1 / Math.sqrt(acc / (NG * NG) + 1e-9);
269
+ }
270
+ function phiAt(k, x, z) {
271
+ const md = modes[k];
272
+ const a = (x + HX) * Math.PI / (2 * HX), b = (z + HX) * Math.PI / (2 * HX);
273
+ return (Math.cos(md.m * a) * Math.cos(md.n * b) + md.s * Math.cos(md.n * a) * Math.cos(md.m * b)) * norm[k];
274
+ }
275
+ const A = new Float32Array(K); // resonant amplitudes (ring up / down)
276
+ const E = new Float32Array(K); // tap ring-down (signed)
277
+ const W = new Float32Array(NG * NG);
278
+ const active = new Int32Array(K);
279
+ const T = new Float32Array(K);
280
+ const topQ = new Int32Array(8), topV = new Float32Array(8);
281
+ let stepNo = 0;
282
+ // ---- powder ----
283
+ const pos = new Float32Array(n * 3), vel = new Float32Array(n * 3);
284
+ const seedA = new Float32Array(n);
285
+ const EN = new Float32Array(n); // local vibration energy (smoothed), drives the colour
286
+ const EXP = new Float32Array(n); // exposure memory of speed (trails)
287
+ const rpos = new Float32Array(n * 3);
288
+ function sprinkle(i, warm) {
289
+ const j = i * 3;
290
+ pos[j] = (rng() * 2 - 1) * HX * 0.97;
291
+ pos[j + 2] = (rng() * 2 - 1) * HX * 0.97;
292
+ pos[j + 1] = warm ? 0 : 0.25 + 0.3 * rng();
293
+ vel[j] = 0;
294
+ vel[j + 1] = warm ? 0 : -0.3;
295
+ vel[j + 2] = 0;
296
+ }
297
+ for (let i = 0; i < n; i++) {
298
+ sprinkle(i, true);
299
+ seedA[i] = rng();
300
+ }
301
+ // ---- pointer agents ----
302
+ const TP = new Float32Array(MAX_TAPS * 4);
303
+ for (let k = 0; k < MAX_TAPS; k++)
304
+ TP[k * 4 + 2] = 99;
305
+ let tapHead = 0, tapClock = 0, lastTapX = 1e9, lastTapZ = 1e9;
306
+ const PP = new Float32Array(NPUFF * 3), PV = new Float32Array(NPUFF * 3), PA = new Float32Array(NPUFF);
307
+ PA.fill(-1);
308
+ let puffHead = 0, puffAcc = 0;
309
+ const hp = new THREE.Vector3(), hv = new THREE.Vector3();
310
+ const WARM = 200 * H_STEP * 2;
311
+ let time = -WARM; // the sweep's phase is 0 when the object appears
312
+ // one uniform draw per call keeps the hop statistics deterministic
313
+ const hr = mulberry32(seedMix(0x7ab5, c.seed));
314
+ function sampleW(x, z, out) {
315
+ let fx = (x + HX) / (2 * HX) * (NG - 1), fz = (z + HX) / (2 * HX) * (NG - 1);
316
+ if (fx < 0)
317
+ fx = 0;
318
+ else if (fx > NG - 1.001)
319
+ fx = NG - 1.001;
320
+ if (fz < 0)
321
+ fz = 0;
322
+ else if (fz > NG - 1.001)
323
+ fz = NG - 1.001;
324
+ const i0 = fx | 0, j0 = fz | 0, tx = fx - i0, tz = fz - j0;
325
+ const o = j0 * NG + i0;
326
+ const w00 = W[o], w10 = W[o + 1], w01 = W[o + NG], w11 = W[o + NG + 1];
327
+ const a = w00 + (w10 - w00) * tx, b = w01 + (w11 - w01) * tx;
328
+ out[0] = a + (b - a) * tz;
329
+ const inv = (NG - 1) / (2 * HX);
330
+ out[1] = ((w10 - w00) + ((w11 - w01) - (w10 - w00)) * tz) * inv;
331
+ out[2] = (b - a) * inv;
332
+ }
333
+ const smp = new Float32Array(3);
334
+ function step(h) {
335
+ time += h;
336
+ const amp = Math.max(u.uAmplitude.value, 0);
337
+ const fEff = u.uFrequency.value * Math.pow(2, u.uSweep.value * Math.sin((6.2831853 * time) / SWEEP_T));
338
+ const bw = Math.max(u.uDamping.value, 0.005);
339
+ // pointer
340
+ const mode = u.uPointerMode.value;
341
+ const S = u.uPointerStrength.value, act = u.uPointerActive.value, Rr = u.uPointerRadius.value;
342
+ hp.copy(u.uPointer.value);
343
+ hv.copy(u.uPointerVel.value);
344
+ const spd = Math.hypot(hv.x, hv.z);
345
+ const bowS = mode === 0 && S > 0.01 ? S * act * Math.min(1, spd / 0.6) : 0;
346
+ const fingS = mode === 1 && S > 0.01 && Math.abs(hp.x) < HX && Math.abs(hp.z) < HX ? S * act : 0;
347
+ // bow point: nearest point on the plate edge
348
+ let bx = Math.max(-HX, Math.min(HX, hp.x)), bz = Math.max(-HX, Math.min(HX, hp.z));
349
+ if (HX - Math.abs(bx) < HX - Math.abs(bz))
350
+ bx = Math.sign(bx || 1) * HX;
351
+ else
352
+ bz = Math.sign(bz || 1) * HX;
353
+ const ex = Math.abs(bx) === HX ? 0 : 1, ez = 1 - ex; // direction along the edge
354
+ // taps
355
+ const useTap = mode === 2 && S > 0.01;
356
+ tapClock += h;
357
+ if (useTap && act > 0.6 && Math.abs(hp.x) < HX && Math.abs(hp.z) < HX) {
358
+ const moved = Math.hypot(hp.x - lastTapX, hp.z - lastTapZ);
359
+ if ((tapClock > 0.4 || moved > 0.16) && tapClock > 0.12) {
360
+ const k = tapHead % MAX_TAPS;
361
+ tapHead++;
362
+ TP[k * 4] = hp.x;
363
+ TP[k * 4 + 1] = hp.z;
364
+ TP[k * 4 + 2] = 0;
365
+ TP[k * 4 + 3] = S * Rr;
366
+ tapClock = 0;
367
+ lastTapX = hp.x;
368
+ lastTapZ = hp.z;
369
+ // broadband ring-down: every mode is struck in proportion to its shape at the tap
370
+ for (let q = 0; q < K; q++)
371
+ E[q] += 0.35 * S * phiAt(q, hp.x, hp.z) / (1 + modes[q].f / 5); // a light global ring-down
372
+ }
373
+ }
374
+ for (let k = 0; k < MAX_TAPS; k++)
375
+ TP[k * 4 + 2] += h;
376
+ const decE = Math.exp(-h / 0.7);
377
+ const kA = 1 - Math.exp(-h / (RING_UP / (1 + 3 * Math.max(bowS, fingS))));
378
+ const bwEff = bw * (1 + 6 * bowS);
379
+ let maxBase = 0, maxNew = 0;
380
+ for (let q = 0; q < K; q++) {
381
+ const d0 = (fEff - modes[q].f) / (bw * modes[q].f);
382
+ const t0 = 1 / ((1 + d0 * d0) * (1 + d0 * d0)); // response power: clean, well-separated figures
383
+ if (t0 > maxBase)
384
+ maxBase = t0;
385
+ const d = (fEff - modes[q].f) / (bwEff * modes[q].f);
386
+ let t = 1 / ((1 + d * d) * (1 + d * d));
387
+ if (bowS > 0) {
388
+ // edge coupling, averaged over the bow width
389
+ let cpl = 0;
390
+ for (let s = -2; s <= 2; s++) {
391
+ const o = s * 0.03 * Rr;
392
+ const p = phiAt(q, Math.max(-HX, Math.min(HX, bx + ex * o)), Math.max(-HX, Math.min(HX, bz + ez * o)));
393
+ cpl += p * p;
394
+ }
395
+ t *= (1 - bowS * 0.95) + bowS * 0.95 * 1.3 * cpl / 5;
396
+ }
397
+ if (fingS > 0) {
398
+ let sup = 0;
399
+ for (let s = 0; s < 5; s++) {
400
+ const ang = s * 1.2566;
401
+ const r = s === 0 ? 0 : 0.035 * Rr;
402
+ const p = phiAt(q, hp.x + r * Math.cos(ang), hp.z + r * Math.sin(ang));
403
+ sup += p * p;
404
+ }
405
+ t *= Math.exp(-5 * fingS * sup / 5);
406
+ }
407
+ T[q] = t;
408
+ if (t > maxNew)
409
+ maxNew = t;
410
+ }
411
+ // the drive's energy goes into the modes the bow / finger allow: keep the loudest one at the
412
+ // undisturbed level (a bow pumps a little more in, a finger takes a little out)
413
+ const target = bowS > 0 ? Math.max(maxBase, 0.6) * (1 + 1.2 * bowS) : fingS > 0 ? maxBase * (1 - 0.2 * fingS / (1 + fingS)) : maxBase;
414
+ const rescale = (bowS > 0 || fingS > 0) && maxNew > 1e-6 ? target / maxNew : 1;
415
+ let nAct = 0, peak = 0;
416
+ for (let q = 0; q < K; q++) {
417
+ const t = T[q] * rescale;
418
+ A[q] += (t - A[q]) * kA;
419
+ E[q] *= decE;
420
+ const v = A[q] + E[q];
421
+ if (Math.abs(v) > 0.03)
422
+ active[nAct++] = q;
423
+ if (Math.abs(v) > peak)
424
+ peak = Math.abs(v);
425
+ }
426
+ stepNo++;
427
+ // vibration field on the grid (modes ring up over ~0.45 s, so every other step is plenty)
428
+ if ((stepNo & 1) === 1 || nAct === 0) {
429
+ W.fill(0);
430
+ for (let a = 0; a < nAct; a++) {
431
+ const q = active[a];
432
+ const { m, n: nn, s } = modes[q];
433
+ const cc = (A[q] + E[q]) * norm[q];
434
+ for (let j = 0; j < NG; j++) {
435
+ const cmj = C[m * NG + j], cnj = C[nn * NG + j];
436
+ const row = j * NG;
437
+ for (let i = 0; i < NG; i++)
438
+ W[row + i] += cc * (C[m * NG + i] * cnj + s * C[nn * NG + i] * cmj);
439
+ }
440
+ }
441
+ }
442
+ // rosin dust
443
+ if (bowS > 0.05) {
444
+ puffAcc += bowS * 90 * h;
445
+ while (puffAcc >= 1) {
446
+ puffAcc -= 1;
447
+ const k = puffHead % NPUFF;
448
+ puffHead++;
449
+ const o = (hr() - 0.5) * 0.08 * Rr;
450
+ PP[k * 3] = bx + ex * o;
451
+ PP[k * 3 + 1] = 0.005;
452
+ PP[k * 3 + 2] = bz + ez * o;
453
+ const outx = ex === 0 ? Math.sign(bx) : 0, outz = ez === 0 ? Math.sign(bz) : 0;
454
+ PV[k * 3] = outx * (0.06 + 0.1 * hr()) + (hr() - 0.5) * 0.08;
455
+ PV[k * 3 + 1] = 0.04 + 0.12 * hr();
456
+ PV[k * 3 + 2] = outz * (0.06 + 0.1 * hr()) + (hr() - 0.5) * 0.08;
457
+ PA[k] = 0;
458
+ }
459
+ }
460
+ for (let k = 0; k < NPUFF; k++) {
461
+ if (PA[k] < 0)
462
+ continue;
463
+ PA[k] += h;
464
+ if (PA[k] > 2.5) {
465
+ PA[k] = -1;
466
+ continue;
467
+ }
468
+ const dmp = Math.exp(-h / 0.6);
469
+ PV[k * 3] *= dmp;
470
+ PV[k * 3 + 1] = PV[k * 3 + 1] * dmp - 0.02 * h;
471
+ PV[k * 3 + 2] *= dmp;
472
+ PP[k * 3] += PV[k * 3] * h;
473
+ PP[k * 3 + 1] += PV[k * 3 + 1] * h;
474
+ PP[k * 3 + 2] += PV[k * 3 + 2] * h;
475
+ }
476
+ // ---- powder ----
477
+ const relax = 1 - Math.exp(-h / 0.3);
478
+ const eRelax = 1 - Math.exp(-h / 0.25);
479
+ const hov = Math.max(u.uHover.value, 0), sw = Math.max(u.uSwirl.value, 0);
480
+ const decEx = Math.exp(-h / Math.max(0.5 * u.uTrailLength.value, 0.1));
481
+ let nTap = 0;
482
+ for (let k = 0; k < MAX_TAPS; k++)
483
+ if (TP[k * 4 + 2] < 1.4 && TP[k * 4 + 3] > 0)
484
+ nTap++;
485
+ // direct contact: the fingertip sweeps the powder aside; the bow's stick-slip puffs it up
486
+ const RF = 0.09 * Rr, RB = 0.3 * Rr;
487
+ const fvx = Math.max(-1.5, Math.min(1.5, hv.x)), fvz = Math.max(-1.5, Math.min(1.5, hv.z));
488
+ for (let i = 0; i < n; i++) {
489
+ const j = i * 3;
490
+ let x = pos[j], y = pos[j + 1], z = pos[j + 2];
491
+ let vx = vel[j], vy = vel[j + 1], vz = vel[j + 2];
492
+ const onPlate = x > -HX && x < HX && z > -HX && z < HX;
493
+ let en = 0;
494
+ if (!onPlate) {
495
+ // blown off the edge: settles out of the air, then is sprinkled back on
496
+ const dr = Math.exp(-h / 0.25);
497
+ vy -= 1.5 * h;
498
+ vx *= dr;
499
+ vz *= dr;
500
+ x += vx * h;
501
+ y += vy * h;
502
+ z += vz * h;
503
+ if (y < -0.6 || x > 1.35 || x < -1.35 || z > 1.35 || z < -1.35 || y > 0.9) {
504
+ sprinkle(i, false);
505
+ x = pos[j];
506
+ y = pos[j + 1];
507
+ z = pos[j + 2];
508
+ vx = vel[j];
509
+ vy = vel[j + 1];
510
+ vz = vel[j + 2];
511
+ if (trail)
512
+ trail.reset(i);
513
+ }
514
+ }
515
+ else {
516
+ sampleW(x, z, smp);
517
+ const w = smp[0], gx = smp[1], gz = smp[2];
518
+ const aw = w < 0 ? -w : w;
519
+ en = amp * amp * w * w;
520
+ const live = amp * aw * GAIN > 0.15 ? 1 : 0;
521
+ // acoustic streaming: up the gradient of w^2, plus a swirl whose sense follows the sign of w
522
+ const k = 0.6 * amp;
523
+ const sg = w >= 0 ? 1 : -1;
524
+ let ux = k * w * gx - 0.25 * sw * sg * gz * aw * amp;
525
+ let uz = k * w * gz + 0.25 * sw * sg * gx * aw * amp;
526
+ const ul = Math.hypot(ux, uz);
527
+ if (ul > 0.35) {
528
+ ux *= 0.35 / ul;
529
+ uz *= 0.35 / ul;
530
+ }
531
+ vx += (ux * live - vx) * relax + (hr() - 0.5) * 0.25 * live * aw;
532
+ vz += (uz * live - vz) * relax + (hr() - 0.5) * 0.25 * live * aw;
533
+ const yT = live ? Math.min(0.012 * hov * amp * w * w * (0.3 + 1.4 * seedA[i]), 0.1 * Math.max(hov, 0.01)) : 0;
534
+ if (y > yT + 0.02)
535
+ vy -= 1.5 * h;
536
+ else
537
+ vy = (yT - y) * 2.5; // settles slowly when lifted high
538
+ x += vx * h;
539
+ y += vy * h;
540
+ z += vz * h;
541
+ if (y < 0)
542
+ y = 0;
543
+ if (fingS > 0.02) {
544
+ const dx = x - hp.x, dz = z - hp.z, d2 = dx * dx + dz * dz;
545
+ if (d2 < RF * RF) {
546
+ const d = Math.sqrt(d2) + 1e-5;
547
+ x = hp.x + dx / d * RF * 1.02;
548
+ z = hp.z + dz / d * RF * 1.02;
549
+ vx = dx / d * 0.3 * fingS + fvx * 0.7;
550
+ vz = dz / d * 0.3 * fingS + fvz * 0.7;
551
+ vy = Math.max(vy, 0.08 + 0.1 * hr());
552
+ en += 0.8 * fingS;
553
+ }
554
+ }
555
+ if (bowS > 0.02) {
556
+ const dx = x - bx, dz = z - bz, d2 = dx * dx + dz * dz;
557
+ if (d2 < RB * RB && hr() < bowS * 0.3 * (1 - Math.sqrt(d2) / RB)) {
558
+ vy = Math.max(vy, 0.2 + 0.3 * hr() * bowS);
559
+ vx += (hr() - 0.5) * 0.3 - (ex === 0 ? Math.sign(bx) * 0.2 : 0);
560
+ vz += (hr() - 0.5) * 0.3 - (ez === 0 ? Math.sign(bz) * 0.2 : 0);
561
+ en += 1.2 * bowS;
562
+ }
563
+ }
564
+ // tap bending waves blow the powder up
565
+ if (nTap) {
566
+ for (let t = 0; t < MAX_TAPS; t++) {
567
+ const age = TP[t * 4 + 2];
568
+ if (age >= 1.4 || TP[t * 4 + 3] <= 0)
569
+ continue;
570
+ const dx = x - TP[t * 4], dz = z - TP[t * 4 + 1];
571
+ const r = Math.sqrt(dx * dx + dz * dz) + 1e-4;
572
+ // the bending wave is damped within the tap's reach (pointerRadius): a local ring
573
+ const reach = 0.3 * TP[t * 4 + 3] / Math.max(S, 0.01);
574
+ const rf = 0.8 * age, wd = 0.025 + 0.04 * age;
575
+ if (rf > 1.3 * reach)
576
+ continue;
577
+ const off = r - rf;
578
+ if (off * off < wd * wd && hr() < 0.8) {
579
+ const e = S * Math.exp(-(rf * rf) / (reach * reach));
580
+ vy = Math.max(vy, 0.35 * e * (0.5 + hr()));
581
+ vx += dx / r * 0.2 * e;
582
+ vz += dz / r * 0.2 * e;
583
+ en += 1.5 * e;
584
+ }
585
+ }
586
+ }
587
+ }
588
+ EN[i] += (en - EN[i]) * eRelax;
589
+ pos[j] = x;
590
+ pos[j + 1] = y;
591
+ pos[j + 2] = z;
592
+ vel[j] = vx;
593
+ vel[j + 1] = vy;
594
+ vel[j + 2] = vz;
595
+ rpos[j] = x;
596
+ rpos[j + 1] = y + 0.004;
597
+ rpos[j + 2] = z;
598
+ const sp = Math.sqrt(vx * vx + vy * vy + vz * vz);
599
+ const e = EXP[i] * decEx;
600
+ EXP[i] = sp > e ? sp : e;
601
+ }
602
+ lastPeak = peak;
603
+ }
604
+ let lastPeak = 0;
605
+ let trail = null, trailKey = '';
606
+ const stepper = createFixedStep(H_STEP, 4);
607
+ for (let s = 0; s < 200; s++)
608
+ step(H_STEP * 2); // the first heaps have gathered
609
+ // ---- geometry ----
610
+ const geo = new THREE.BufferGeometry();
611
+ const posAttr = new THREE.BufferAttribute(rpos, 3).setUsage(THREE.DynamicDrawUsage);
612
+ const eAttr = new THREE.BufferAttribute(EN, 1).setUsage(THREE.DynamicDrawUsage);
613
+ geo.setAttribute('position', posAttr);
614
+ geo.setAttribute('aE', eAttr);
615
+ geo.setAttribute('aSeed', new THREE.BufferAttribute(seedA, 1));
616
+ const mat = new THREE.ShaderMaterial({
617
+ uniforms: u, vertexShader: powderVertex, fragmentShader: powderFragment,
618
+ transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
619
+ });
620
+ const powder = new THREE.Points(geo, mat);
621
+ powder.frustumCulled = false;
622
+ attachViewH(powder, u);
623
+ const fgeo = new THREE.BufferGeometry();
624
+ const ppAttr = new THREE.BufferAttribute(PP, 3).setUsage(THREE.DynamicDrawUsage);
625
+ const paAttr = new THREE.BufferAttribute(PA, 1).setUsage(THREE.DynamicDrawUsage);
626
+ fgeo.setAttribute('position', ppAttr);
627
+ fgeo.setAttribute('aAge', paAttr);
628
+ const fmat = new THREE.ShaderMaterial({
629
+ uniforms: u, vertexShader: puffVertex, fragmentShader: puffFragment,
630
+ transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
631
+ });
632
+ const puffs = new THREE.Points(fgeo, fmat);
633
+ puffs.frustumCulled = false;
634
+ puffs.renderOrder = 3;
635
+ const object = new THREE.Group();
636
+ const proxy = createPickProxy(BOUNDS.center, BOUNDS.radius);
637
+ object.add(powder, puffs, proxy.mesh);
638
+ const anchor = new THREE.Vector3(0, 0, 0);
639
+ const up = new THREE.Vector3(0, 1, 0);
640
+ // ---- trails: moving powder, coloured by the same heat ramp ----
641
+ const trailGLSL = /* glsl */ `
642
+ ${HEAT_GLSL}
643
+ uniform float uTrailGain;
644
+ vec3 trailShade(vec3 p, float f) {
645
+ float e = clamp(TRAIL_K * 2.2, 0.0, 1.0);
646
+ return heat(0.6 + 1.2 * e) * e * 0.55 * uTrailGain;
647
+ }
648
+ vec3 trailView(vec3 mv, out float mag) { mag = 1.0; return mv; }
649
+ `;
650
+ const materials = [mat, fmat];
651
+ function buildTrails(mode, share) {
652
+ const key = mode + ':' + share;
653
+ if (key === trailKey)
654
+ return;
655
+ trailKey = key;
656
+ if (trail) {
657
+ object.remove(trail.object);
658
+ trail.dispose();
659
+ trail = null;
660
+ }
661
+ materials.length = 2;
662
+ u.uTrailGain.value = TRAIL_GAIN[mode] || 1;
663
+ trail = createCpuTrail(mode, { u, n, pos: rpos, vel, k: EXP, glsl: trailGLSL, sizePx: 0.005, spacing: 0.002, sub: 8 });
664
+ if (!trail)
665
+ return;
666
+ object.add(trail.object);
667
+ materials.push(...trail.materials);
668
+ }
669
+ buildTrails(c.trailMode, c.trailShare);
670
+ function upload() {
671
+ posAttr.needsUpdate = true;
672
+ eAttr.needsUpdate = true;
673
+ ppAttr.needsUpdate = true;
674
+ paAttr.needsUpdate = true;
675
+ }
676
+ return {
677
+ object,
678
+ materials,
679
+ apply(cfg) { if (cfg)
680
+ buildTrails(cfg.trailMode, cfg.trailShare); },
681
+ update(dt, camera, pointer) {
682
+ const rdt = Math.min(Math.max(dt, 0), 1 / 20);
683
+ if (camera && !inView(object, camera, BOUNDS.center, BOUNDS.radius, u.uSize.value))
684
+ return;
685
+ const d = rdt * u.uSpeed.value;
686
+ u.uTime.value += d;
687
+ if (camera && pointer)
688
+ stepPointerField(u, pointer, camera, object, u.uSize.value, rdt, anchor, up);
689
+ u.uVelScale.value = 1;
690
+ let simD = 0;
691
+ stepper.run(d, (hh) => { step(hh); simD += hh; });
692
+ if (simD > 0) {
693
+ upload();
694
+ if (trail)
695
+ trail.update(simD);
696
+ }
697
+ },
698
+ seek(t) { for (let s = 0; s < Math.round(t / H_STEP); s++)
699
+ step(H_STEP); upload(); },
700
+ dispose() {
701
+ geo.dispose();
702
+ mat.dispose();
703
+ fgeo.dispose();
704
+ fmat.dispose();
705
+ proxy.dispose();
706
+ if (trail)
707
+ trail.dispose();
708
+ },
709
+ _state: { pos, vel, EN, A, E, W, get peak() { return lastPeak; } },
710
+ };
711
+ }
712
+ /** Defaults for every knob, so an older saved config still renders (identical to the catalog entry). */
713
+ const DEFAULTS = {
714
+ intensity: 1,
715
+ speed: 1,
716
+ opacity: 0.9,
717
+ size: 1,
718
+ color: '#ff8a2a',
719
+ particleCount: 12000,
720
+ frequency: 4.63,
721
+ sweep: 0.3,
722
+ damping: 0.025,
723
+ amplitude: 1,
724
+ glowColor: '#ffe7a6',
725
+ hover: 1,
726
+ swirl: 1,
727
+ puffSize: 1,
728
+ seed: 1,
729
+ quality: 'medium',
730
+ pointerMode: 'bow',
731
+ pointerStrength: 1,
732
+ pointerRadius: 1,
733
+ pointerColor: '#fff4dc',
734
+ trailMode: 'comet',
735
+ trailLength: 1.6,
736
+ trailOpacity: 0.6,
737
+ trailColor: '#ff5a1e',
738
+ trailShare: 0.6,
739
+ };
740
+ /** Knobs that rebuild the simulation: they change on commit, never through the live path. */
741
+ const STRUCTURAL = ['particleCount', 'seed', 'quality'];
742
+ /** Knobs an animation (or mouse move) may drive per frame — generated from applyConfig. */
743
+ const ANIMATABLE = new Set(GENERATIVE_PARTICLES_ANIMATABLE.faradayPowder.map((p) => p.value));
744
+ const FaradayPowderParticles = ({ id, size, config, animations, onObjectReady, onObjectDispose }) => {
745
+ const rootRef = useRef(null);
746
+ useObjectReady(rootRef, config, onObjectReady, onObjectDispose);
747
+ const s = size > 0 ? size : 1;
748
+ const cfg = useMemo(() => ({ ...DEFAULTS, ...config }), [config]);
749
+ // uniforms outlive the objects, so keyframe animations survive a rebuild
750
+ const uniforms = useMemo(() => createUniforms(), []);
751
+ // rebuilt only when a structural knob changes (trails rebuild inside apply())
752
+ const instance = useMemo(() => build(uniforms, cfg, s), [uniforms, cfg.particleCount, cfg.seed, cfg.quality]);
753
+ useEffect(() => () => instance.dispose(), [instance]);
754
+ useLayoutEffect(() => {
755
+ applyConfig(uniforms, cfg, s);
756
+ instance.apply(cfg);
757
+ }, [instance, uniforms, cfg, s]);
758
+ // Slider drags and keyframe animations are both overrides on the committed config.
759
+ // This is the one place they are merged and pushed, so they compose instead of
760
+ // overwriting each other; the animation wins, because it is the active driver.
761
+ const keyframes = animations?.particles?.animations;
762
+ const animOverride = useRef({});
763
+ const animFrom = useRef({});
764
+ const animStart = useRef(0);
765
+ // Editing a keyframe restarts its cycle rather than dropping the user mid-way.
766
+ useEffect(() => { animStart.current = performance.now(); animFrom.current = {}; }, [keyframes]);
767
+ const push = useCallback((overrides) => {
768
+ const merged = { ...cfg, ...overrides, ...animOverride.current };
769
+ const z = merged.size;
770
+ applyConfig(uniforms, merged, z && z > 0 ? z : s);
771
+ }, [cfg, uniforms, s]);
772
+ const live = useConfigLiveHandle(rootRef, id, 'particles', cfg, STRUCTURAL, push);
773
+ const pointer = usePointerField();
774
+ useFrame((state, delta) => {
775
+ instance.update(Math.min(delta, 0.1), state.camera, pointer);
776
+ if (keyframes?.length) {
777
+ applyConfigKeyframeAnimations(keyframes, animFrom.current, cfg, animOverride.current, performance.now() - animStart.current, ANIMATABLE);
778
+ push(live.current);
779
+ }
780
+ });
781
+ return (_jsx("group", { ref: rootRef, children: _jsx("primitive", { object: instance.object }) }));
782
+ };
783
+ export default FaradayPowderParticles;