@3driseai/3drise-viewer 0.1.19 → 0.1.20

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 (30) 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/hooks/index.d.ts +1 -0
  23. package/dist/hooks/index.d.ts.map +1 -1
  24. package/dist/hooks/index.js +1 -0
  25. package/dist/hooks/useMaterialApplication.d.ts.map +1 -1
  26. package/dist/hooks/useMaterialApplication.js +1 -0
  27. package/dist/hooks/useNodeOverrides.d.ts +13 -0
  28. package/dist/hooks/useNodeOverrides.d.ts.map +1 -0
  29. package/dist/hooks/useNodeOverrides.js +254 -0
  30. package/package.json +2 -2
@@ -0,0 +1,912 @@
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
+ * Chladni Sand — Sand on an invisible vibrating plate: resonant plate modes throw grains onto the nodal lines, drawing Chladni figures that form and re-form; the cursor bows, touches or taps the plate. Use for science, sound and abstract scenes.
13
+ */
14
+ // Chladni Sand — sand dancing on a bowed metal plate (cymatics, Chladni 1787). Particles only: the
15
+ // plate itself is invisible; the sand draws the figures.
16
+ // Movement: the plate is a centre-clamped square plate. Its standing waves are the classic Chladni
17
+ // combinations phi = cos(m pi u) cos(n pi v) +/- cos(n pi u) cos(m pi v), each ringing with a
18
+ // Lorentzian resonance around its own frequency (the + and - pair are split, the seed adds
19
+ // small plate imperfections). The drive frequency glides slowly through the resonances, so
20
+ // figures form, dissolve and re-form. Where the plate's acceleration exceeds g (Gamma > 1)
21
+ // grains are thrown up; each hop lands a random step away with a step length that grows
22
+ // with the local vibration, so grains random-walk out of the moving regions and freeze on
23
+ // the nodal lines. Grains that hop off the edge fall away and are sprinkled back on.
24
+ // Colour: the sand is lit by a low raking light over its own pile height (density -> heightfield
25
+ // -> normal), so the ridges read in relief; a few dark mineral grains; grains in flight
26
+ // leave the pile's shadow and catch the light in the second colour.
27
+ // Mouse (pointerMode; the cursor lives on the plate plane):
28
+ // bow — a violin bow on the nearest point of the edge: modes with an antinode there are
29
+ // favoured and bowing speed pumps energy in; rosin dust puffs off the edge.
30
+ // finger — a fingertip touching the plate forces a node there: modes that move under it are
31
+ // damped, so the figure snaps to one with a nodal line through the finger.
32
+ // tap — taps along the cursor path: a dispersive bending wave rings outward, throwing grains
33
+ // in an expanding ring, and the broadband ring-down scrambles the figure for a moment.
34
+ // Trails (trailMode): streak = long exposure (hop arcs), comet = migration paths, echo = dust haze.
35
+ //
36
+ // Generated from the approved lab module (particle lab, batch 3). Sim units = object-local / size.
37
+ // ---------------------------------------------------------------------------------------------
38
+ // Engine features
39
+ // ---------------------------------------------------------------------------------------------
40
+ const QUALITY_SCALE = { low: 0.5, medium: 1, high: 1.6 };
41
+ /** mulberry32 — seeded PRNG, returns a function giving floats in [0,1). */
42
+ function mulberry32(seed) {
43
+ let a = seed >>> 0;
44
+ return function () {
45
+ a = (a + 0x6d2b79f5) >>> 0;
46
+ let t = a;
47
+ t = Math.imul(t ^ (t >>> 15), t | 1);
48
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
49
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
50
+ };
51
+ }
52
+ /** Standard normal from a uniform PRNG (Box–Muller, one value per call). */
53
+ function gauss(rng) {
54
+ const u = Math.max(rng(), 1e-9);
55
+ const v = rng();
56
+ return Math.sqrt(-2 * Math.log(u)) * Math.cos(6.283185307 * v);
57
+ }
58
+ /** Viewport-height hook so point sizes are pixel-correct at any canvas size. */
59
+ function attachViewH(obj, u) {
60
+ const scratch = new THREE.Vector2();
61
+ obj.onBeforeRender = (renderer) => {
62
+ renderer.getDrawingBufferSize(scratch);
63
+ u.uViewH.value = scratch.y;
64
+ };
65
+ }
66
+ function colorUniform(hex) {
67
+ return { value: new THREE.Color(hex) };
68
+ }
69
+ function qualityScale(q) { return QUALITY_SCALE[q] || 1; }
70
+ function seedMix(base, seed) {
71
+ let h = (base ^ Math.imul((Math.round(seed) | 0) + 0x9e37, 0x85ebca6b)) >>> 0;
72
+ h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35) >>> 0;
73
+ return (h ^ (h >>> 16)) >>> 0;
74
+ }
75
+ function createFixedStep(H, maxSteps) {
76
+ let acc = 0;
77
+ return {
78
+ H,
79
+ run(d, step) {
80
+ acc += Math.max(d, 0);
81
+ let k = 0;
82
+ while (acc >= H && k < maxSteps) {
83
+ step(H);
84
+ acc -= H;
85
+ k++;
86
+ }
87
+ if (k === maxSteps)
88
+ acc = Math.min(acc, H);
89
+ return acc / H;
90
+ },
91
+ reset() { acc = 0; },
92
+ };
93
+ }
94
+ function createPickProxy(center, radius) {
95
+ const geo = new THREE.SphereGeometry(1, 16, 10);
96
+ const mat = new THREE.MeshBasicMaterial({ visible: false });
97
+ const mesh = new THREE.Mesh(geo, mat);
98
+ mesh.name = 'pickProxy';
99
+ mesh.position.set(center[0], center[1], center[2]);
100
+ mesh.scale.setScalar(radius);
101
+ return { mesh, dispose() { geo.dispose(); mat.dispose(); } };
102
+ }
103
+ const _frustum = new THREE.Frustum();
104
+ const _pm = new THREE.Matrix4();
105
+ const _sphere = new THREE.Sphere();
106
+ function inView(object, camera, center, radius, size) {
107
+ if (!camera || !camera.projectionMatrix)
108
+ return true;
109
+ object.updateWorldMatrix(true, false);
110
+ _sphere.center.set(center[0], center[1], center[2]).multiplyScalar(size).applyMatrix4(object.matrixWorld);
111
+ _sphere.radius = radius * size * object.matrixWorld.getMaxScaleOnAxis();
112
+ _pm.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
113
+ _frustum.setFromProjectionMatrix(_pm);
114
+ return _frustum.intersectsSphere(_sphere);
115
+ }
116
+ const POINTER_MODES = ['bow', 'finger', 'tap'];
117
+ const HX = 1; // half size of the plate (sim units)
118
+ const NG = 97; // vibration grid (odd -> a sample at the clamped centre)
119
+ const DG = 72; // sand heightfield grid
120
+ const NMAX = 10; // highest mode index
121
+ const H_STEP = 1 / 60;
122
+ const G_SIM = 7.5; // gravity for the hops (slow, calm)
123
+ const GAIN = 1.7; // Gamma per unit of normalised displacement at amplitude 1
124
+ const SWEEP_T = 46; // seconds per sweep cycle
125
+ const RING_UP = 0.45; // mode amplitude time constant (the plate rings up / down)
126
+ const MAX_TAPS = 4;
127
+ const NPUFF = 320;
128
+ const BOUNDS = { center: [0, 0, 0], radius: 1.55 };
129
+ const TRAIL_GAIN = { streak: 3, comet: 2.5, echo: 5 };
130
+ function createUniforms() {
131
+ return {
132
+ uTime: { value: 0 },
133
+ uSize: { value: 1 },
134
+ uViewH: { value: 800 },
135
+ uIntensity: { value: 1 },
136
+ uSpeed: { value: 1 },
137
+ uOpacity: { value: 0.9 },
138
+ uColor: colorUniform('#ffffff'),
139
+ uParticleCount: { value: 0 },
140
+ uFrequency: { value: 5 },
141
+ uSweep: { value: 0.3 },
142
+ uDamping: { value: 0.05 },
143
+ uAmplitude: { value: 1 },
144
+ uHopColor: colorUniform('#ffffff'),
145
+ uLightAngle: { value: 22 },
146
+ uGrainSize: { value: 1 },
147
+ uPointerMode: { value: 0 },
148
+ uPointerStrength: { value: 1 },
149
+ uPointerRadius: { value: 1 },
150
+ uPointerColor: colorUniform('#ffffff'),
151
+ uTrailGain: { value: 1 },
152
+ ...createPointerUniforms(),
153
+ ...createTrailUniforms(),
154
+ };
155
+ }
156
+ function applyConfig(u, c, size) {
157
+ u.uSize.value = size;
158
+ u.uIntensity.value = c.intensity;
159
+ u.uSpeed.value = c.speed;
160
+ u.uOpacity.value = c.opacity;
161
+ u.uColor.value.set(c.color);
162
+ u.uParticleCount.value = c.particleCount;
163
+ u.uFrequency.value = c.frequency;
164
+ u.uSweep.value = c.sweep;
165
+ u.uDamping.value = c.damping;
166
+ u.uAmplitude.value = c.amplitude;
167
+ u.uHopColor.value.set(c.hopColor);
168
+ u.uLightAngle.value = c.lightAngle;
169
+ u.uGrainSize.value = c.grainSize;
170
+ u.uPointerMode.value = POINTER_MODES.indexOf(c.pointerMode); // -1 = 'none' (off)
171
+ u.uPointerStrength.value = c.pointerStrength;
172
+ u.uPointerRadius.value = c.pointerRadius;
173
+ u.uPointerColor.value.set(c.pointerColor);
174
+ applyTrailConfig(u, c);
175
+ }
176
+ const BASE_U = 'uniform float uSize, uViewH, uIntensity, uOpacity;';
177
+ // ---- grains: lit over their heightfield; in flight they take the hop colour ----
178
+ const grainVertex = /* glsl */ `
179
+ ${BASE_U}
180
+ uniform vec3 uColor, uHopColor;
181
+ uniform float uGrainSize;
182
+ attribute float aShade; // raking-light factor (1 = flat); >= 1.15 means the grain is in flight
183
+ attribute float aSeed; // size / mineral seed
184
+ varying vec3 vCol;
185
+ varying float vA;
186
+ void main() {
187
+ vec4 mv = modelViewMatrix * vec4(position * uSize, 1.0);
188
+ // mineral mix: a few dark grains (magnetite), the rest quartz of slightly varying tone
189
+ float tone = aSeed < 0.07 ? 0.38 : 0.82 + 0.36 * fract(aSeed * 17.3);
190
+ float flying = step(1.15, aShade);
191
+ vCol = mix(uColor * tone * aShade, uHopColor * min(aShade, 1.6), flying) * uIntensity;
192
+ vA = uOpacity;
193
+ float px = uGrainSize * uSize * uViewH * 0.0085 * (0.75 + 0.5 * aSeed) / -mv.z;
194
+ gl_PointSize = clamp(px, 1.0, 24.0);
195
+ gl_Position = projectionMatrix * mv;
196
+ }
197
+ `;
198
+ const grainFragment = /* glsl */ `
199
+ varying vec3 vCol;
200
+ varying float vA;
201
+ void main() {
202
+ vec2 d = gl_PointCoord * 2.0 - 1.0;
203
+ float r2 = dot(d, d);
204
+ if (r2 > 1.0) discard;
205
+ // a small lit sphere, a touch brighter toward the light side
206
+ float a = (1.0 - smoothstep(0.55, 1.0, r2)) * (1.0 - 0.125 * (d.x - d.y)) * vA;
207
+ if (a < 0.01) discard;
208
+ gl_FragColor = vec4(vCol, a);
209
+ }
210
+ `;
211
+ // rosin dust off the bowed edge
212
+ const puffVertex = /* glsl */ `
213
+ ${BASE_U}
214
+ attribute float aAge;
215
+ varying float vA;
216
+ void main() {
217
+ vec4 mv = modelViewMatrix * vec4(position * uSize, 1.0);
218
+ vA = aAge < 0.0 ? 0.0 : exp(-aAge / 0.5) * smoothstep(0.0, 0.05, aAge) * uOpacity;
219
+ 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);
220
+ gl_Position = projectionMatrix * mv;
221
+ }
222
+ `;
223
+ const puffFragment = /* glsl */ `
224
+ uniform vec3 uPointerColor;
225
+ uniform float uIntensity;
226
+ varying float vA;
227
+ void main() {
228
+ vec2 d = gl_PointCoord * 2.0 - 1.0;
229
+ float r2 = dot(d, d);
230
+ if (r2 > 1.0) discard;
231
+ float a = exp(-r2 * 3.5) * (1.0 - r2) * vA * 0.6;
232
+ if (a < 0.004) discard;
233
+ gl_FragColor = vec4(uPointerColor * uIntensity, a);
234
+ }
235
+ `;
236
+ function build(u, c, size) {
237
+ const q = qualityScale(c.quality);
238
+ const n = Math.max(500, Math.round(c.particleCount * q));
239
+ const rng = mulberry32(seedMix(0xc4a1, c.seed));
240
+ // ---- modes: Chladni pairs with split +/- frequencies and small seeded imperfections ----
241
+ const modes = [];
242
+ for (let m = 0; m <= NMAX; m++)
243
+ for (let nn = m; nn <= NMAX; nn++) {
244
+ for (const s of m === nn ? [1] : [1, -1]) {
245
+ const f0 = Math.sqrt(m * m + nn * nn);
246
+ if (f0 < 1.9)
247
+ continue;
248
+ modes.push({ m, n: nn, s, f: f0 * (1 + (m === nn ? 0 : s * 0.035)) * (1 + 0.012 * gauss(rng)) });
249
+ }
250
+ }
251
+ const K = modes.length;
252
+ // cosine tables on the grid, and each mode's RMS so every shape has unit RMS
253
+ const C = new Float32Array((NMAX + 1) * NG);
254
+ for (let m = 0; m <= NMAX; m++)
255
+ for (let i = 0; i < NG; i++)
256
+ C[m * NG + i] = Math.cos((m * Math.PI * i) / (NG - 1));
257
+ const norm = new Float32Array(K);
258
+ for (let k = 0; k < K; k++) {
259
+ const { m, n: nn, s } = modes[k];
260
+ let acc = 0;
261
+ for (let j = 0; j < NG; j++)
262
+ for (let i = 0; i < NG; i++) {
263
+ const v = C[m * NG + i] * C[nn * NG + j] + s * C[nn * NG + i] * C[m * NG + j];
264
+ acc += v * v;
265
+ }
266
+ norm[k] = 1 / Math.sqrt(acc / (NG * NG) + 1e-9);
267
+ }
268
+ function phiAt(k, x, z) {
269
+ const md = modes[k];
270
+ const a = (x + HX) * Math.PI / (2 * HX), b = (z + HX) * Math.PI / (2 * HX);
271
+ 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];
272
+ }
273
+ const A = new Float32Array(K); // resonant amplitudes (ring up / down)
274
+ const E = new Float32Array(K); // tap ring-down (signed)
275
+ const W = new Float32Array(NG * NG);
276
+ const active = new Int32Array(K);
277
+ const T = new Float32Array(K);
278
+ const topQ = new Int32Array(8), topV = new Float32Array(8);
279
+ let stepNo = 0;
280
+ // ---- grains ----
281
+ const pos = new Float32Array(n * 3), vel = new Float32Array(n * 3);
282
+ const shade = new Float32Array(n);
283
+ const seedA = new Float32Array(n);
284
+ const EXP = new Float32Array(n);
285
+ const ground = new Uint8Array(n); // 1 resting on the plate
286
+ const thr = new Float32Array(n);
287
+ const pileY = new Float32Array(n); // per-grain jitter inside the pile
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
+ if (warm) {
293
+ pos[j + 1] = 0;
294
+ ground[i] = 1;
295
+ vel[j] = vel[j + 1] = vel[j + 2] = 0;
296
+ }
297
+ else {
298
+ pos[j + 1] = 0.35 + 0.35 * rng();
299
+ ground[i] = 0;
300
+ vel[j] = 0;
301
+ vel[j + 1] = -0.6;
302
+ vel[j + 2] = 0;
303
+ }
304
+ }
305
+ for (let i = 0; i < n; i++) {
306
+ sprinkle(i, true);
307
+ seedA[i] = rng();
308
+ thr[i] = 1 + 0.12 * gauss(rng);
309
+ pileY[i] = rng();
310
+ shade[i] = 1;
311
+ }
312
+ // ---- pointer agents ----
313
+ const TP = new Float32Array(MAX_TAPS * 4);
314
+ for (let k = 0; k < MAX_TAPS; k++)
315
+ TP[k * 4 + 2] = 99;
316
+ let tapHead = 0, tapClock = 0, lastTapX = 1e9, lastTapZ = 1e9;
317
+ const PP = new Float32Array(NPUFF * 3), PV = new Float32Array(NPUFF * 3), PA = new Float32Array(NPUFF);
318
+ PA.fill(-1);
319
+ let puffHead = 0, puffAcc = 0;
320
+ const hp = new THREE.Vector3(), hv = new THREE.Vector3();
321
+ const WARM = 200 * H_STEP * 2;
322
+ let time = -WARM; // the sweep's phase is 0 when the object appears
323
+ // one uniform draw per call keeps the hop statistics deterministic
324
+ const hr = mulberry32(seedMix(0x7ab5, c.seed));
325
+ function sampleW(x, z, out) {
326
+ let fx = (x + HX) / (2 * HX) * (NG - 1), fz = (z + HX) / (2 * HX) * (NG - 1);
327
+ if (fx < 0)
328
+ fx = 0;
329
+ else if (fx > NG - 1.001)
330
+ fx = NG - 1.001;
331
+ if (fz < 0)
332
+ fz = 0;
333
+ else if (fz > NG - 1.001)
334
+ fz = NG - 1.001;
335
+ const i0 = fx | 0, j0 = fz | 0, tx = fx - i0, tz = fz - j0;
336
+ const o = j0 * NG + i0;
337
+ const w00 = W[o], w10 = W[o + 1], w01 = W[o + NG], w11 = W[o + NG + 1];
338
+ const a = w00 + (w10 - w00) * tx, b = w01 + (w11 - w01) * tx;
339
+ out[0] = a + (b - a) * tz;
340
+ const inv = (NG - 1) / (2 * HX);
341
+ out[1] = ((w10 - w00) + ((w11 - w01) - (w10 - w00)) * tz) * inv;
342
+ out[2] = (b - a) * inv;
343
+ }
344
+ const smp = new Float32Array(3);
345
+ function step(h) {
346
+ time += h;
347
+ const amp = Math.max(u.uAmplitude.value, 0);
348
+ const fEff = u.uFrequency.value * Math.pow(2, u.uSweep.value * Math.sin((6.2831853 * time) / SWEEP_T));
349
+ const bw = Math.max(u.uDamping.value, 0.005);
350
+ // pointer
351
+ const mode = u.uPointerMode.value;
352
+ const S = u.uPointerStrength.value, act = u.uPointerActive.value, Rr = u.uPointerRadius.value;
353
+ hp.copy(u.uPointer.value);
354
+ hv.copy(u.uPointerVel.value);
355
+ const spd = Math.hypot(hv.x, hv.z);
356
+ const bowS = mode === 0 && S > 0.01 ? S * act * Math.min(1, spd / 0.6) : 0;
357
+ const fingS = mode === 1 && S > 0.01 && Math.abs(hp.x) < HX && Math.abs(hp.z) < HX ? S * act : 0;
358
+ // bow point: nearest point on the plate edge
359
+ let bx = Math.max(-HX, Math.min(HX, hp.x)), bz = Math.max(-HX, Math.min(HX, hp.z));
360
+ if (HX - Math.abs(bx) < HX - Math.abs(bz))
361
+ bx = Math.sign(bx || 1) * HX;
362
+ else
363
+ bz = Math.sign(bz || 1) * HX;
364
+ const ex = Math.abs(bx) === HX ? 0 : 1, ez = 1 - ex; // direction along the edge
365
+ // taps
366
+ const useTap = mode === 2 && S > 0.01;
367
+ tapClock += h;
368
+ if (useTap && act > 0.6 && Math.abs(hp.x) < HX && Math.abs(hp.z) < HX) {
369
+ const moved = Math.hypot(hp.x - lastTapX, hp.z - lastTapZ);
370
+ if ((tapClock > 0.4 || moved > 0.16) && tapClock > 0.12) {
371
+ const k = tapHead % MAX_TAPS;
372
+ tapHead++;
373
+ TP[k * 4] = hp.x;
374
+ TP[k * 4 + 1] = hp.z;
375
+ TP[k * 4 + 2] = 0;
376
+ TP[k * 4 + 3] = S * Rr;
377
+ tapClock = 0;
378
+ lastTapX = hp.x;
379
+ lastTapZ = hp.z;
380
+ // broadband ring-down: every mode is struck in proportion to its shape at the tap
381
+ for (let q = 0; q < K; q++)
382
+ E[q] += 0.35 * S * phiAt(q, hp.x, hp.z) / (1 + modes[q].f / 5); // a light global ring-down
383
+ }
384
+ }
385
+ for (let k = 0; k < MAX_TAPS; k++)
386
+ TP[k * 4 + 2] += h;
387
+ const decE = Math.exp(-h / 0.7);
388
+ const kA = 1 - Math.exp(-h / (RING_UP / (1 + 3 * Math.max(bowS, fingS))));
389
+ const bwEff = bw * (1 + 6 * bowS);
390
+ let maxBase = 0, maxNew = 0;
391
+ for (let q = 0; q < K; q++) {
392
+ const d0 = (fEff - modes[q].f) / (bw * modes[q].f);
393
+ const t0 = 1 / ((1 + d0 * d0) * (1 + d0 * d0)); // response power: clean, well-separated figures
394
+ if (t0 > maxBase)
395
+ maxBase = t0;
396
+ const d = (fEff - modes[q].f) / (bwEff * modes[q].f);
397
+ let t = 1 / ((1 + d * d) * (1 + d * d));
398
+ if (bowS > 0) {
399
+ // edge coupling, averaged over the bow width
400
+ let cpl = 0;
401
+ for (let s = -2; s <= 2; s++) {
402
+ const o = s * 0.03 * Rr;
403
+ const p = phiAt(q, Math.max(-HX, Math.min(HX, bx + ex * o)), Math.max(-HX, Math.min(HX, bz + ez * o)));
404
+ cpl += p * p;
405
+ }
406
+ t *= (1 - bowS * 0.95) + bowS * 0.95 * 1.3 * cpl / 5;
407
+ }
408
+ if (fingS > 0) {
409
+ let sup = 0;
410
+ for (let s = 0; s < 5; s++) {
411
+ const ang = s * 1.2566;
412
+ const r = s === 0 ? 0 : 0.035 * Rr;
413
+ const p = phiAt(q, hp.x + r * Math.cos(ang), hp.z + r * Math.sin(ang));
414
+ sup += p * p;
415
+ }
416
+ t *= Math.exp(-5 * fingS * sup / 5);
417
+ }
418
+ T[q] = t;
419
+ if (t > maxNew)
420
+ maxNew = t;
421
+ }
422
+ // the drive's energy goes into the modes the bow / finger allow: keep the loudest one at the
423
+ // undisturbed level (a bow pumps a little more in, a finger takes a little out)
424
+ const target = bowS > 0 ? Math.max(maxBase, 0.6) * (1 + 1.2 * bowS) : fingS > 0 ? maxBase * (1 - 0.2 * fingS / (1 + fingS)) : maxBase;
425
+ const rescale = (bowS > 0 || fingS > 0) && maxNew > 1e-6 ? target / maxNew : 1;
426
+ let nAct = 0, peak = 0;
427
+ for (let q = 0; q < K; q++) {
428
+ const t = T[q] * rescale;
429
+ A[q] += (t - A[q]) * kA;
430
+ E[q] *= decE;
431
+ const v = A[q] + E[q];
432
+ if (Math.abs(v) > 0.03)
433
+ active[nAct++] = q;
434
+ if (Math.abs(v) > peak)
435
+ peak = Math.abs(v);
436
+ }
437
+ stepNo++;
438
+ // vibration field on the grid (modes ring up over ~0.45 s, so every other step is plenty)
439
+ if ((stepNo & 1) === 1 || nAct === 0) {
440
+ W.fill(0);
441
+ for (let a = 0; a < nAct; a++) {
442
+ const q = active[a];
443
+ const { m, n: nn, s } = modes[q];
444
+ const cc = (A[q] + E[q]) * norm[q];
445
+ for (let j = 0; j < NG; j++) {
446
+ const cmj = C[m * NG + j], cnj = C[nn * NG + j];
447
+ const row = j * NG;
448
+ for (let i = 0; i < NG; i++)
449
+ W[row + i] += cc * (C[m * NG + i] * cnj + s * C[nn * NG + i] * cmj);
450
+ }
451
+ }
452
+ }
453
+ // rosin dust
454
+ if (bowS > 0.05) {
455
+ puffAcc += bowS * 90 * h;
456
+ while (puffAcc >= 1) {
457
+ puffAcc -= 1;
458
+ const k = puffHead % NPUFF;
459
+ puffHead++;
460
+ const o = (hr() - 0.5) * 0.08 * Rr;
461
+ PP[k * 3] = bx + ex * o;
462
+ PP[k * 3 + 1] = 0.005;
463
+ PP[k * 3 + 2] = bz + ez * o;
464
+ const outx = ex === 0 ? Math.sign(bx) : 0, outz = ez === 0 ? Math.sign(bz) : 0;
465
+ PV[k * 3] = outx * (0.06 + 0.1 * hr()) + (hr() - 0.5) * 0.08;
466
+ PV[k * 3 + 1] = 0.04 + 0.12 * hr();
467
+ PV[k * 3 + 2] = outz * (0.06 + 0.1 * hr()) + (hr() - 0.5) * 0.08;
468
+ PA[k] = 0;
469
+ }
470
+ }
471
+ for (let k = 0; k < NPUFF; k++) {
472
+ if (PA[k] < 0)
473
+ continue;
474
+ PA[k] += h;
475
+ if (PA[k] > 2.5) {
476
+ PA[k] = -1;
477
+ continue;
478
+ }
479
+ const dmp = Math.exp(-h / 0.6);
480
+ PV[k * 3] *= dmp;
481
+ PV[k * 3 + 1] = PV[k * 3 + 1] * dmp - 0.02 * h;
482
+ PV[k * 3 + 2] *= dmp;
483
+ PP[k * 3] += PV[k * 3] * h;
484
+ PP[k * 3 + 1] += PV[k * 3 + 1] * h;
485
+ PP[k * 3 + 2] += PV[k * 3 + 2] * h;
486
+ }
487
+ // ---- grains ----
488
+ const gain = amp * GAIN;
489
+ const HOP = 0.62, HSTEP = 0.22, DRIFT = 0.05;
490
+ const fric = Math.exp(-h / 0.04);
491
+ const decEx = Math.exp(-h / Math.max(0.5 * u.uTrailLength.value, 0.1));
492
+ let nTap = 0;
493
+ for (let k = 0; k < MAX_TAPS; k++)
494
+ if (TP[k * 4 + 2] < 1.4 && TP[k * 4 + 3] > 0)
495
+ nTap++;
496
+ // direct contact: the fingertip ploughs through the sand; the bow's stick-slip rattles the edge
497
+ const RF = 0.07 * Rr, RB = 0.28 * Rr;
498
+ const handOn = fingS > 0.02 || bowS > 0.02;
499
+ const fvx = Math.max(-1.5, Math.min(1.5, hv.x)), fvz = Math.max(-1.5, Math.min(1.5, hv.z));
500
+ for (let i = 0; i < n; i++) {
501
+ const j = i * 3;
502
+ let x = pos[j], y = pos[j + 1], z = pos[j + 2];
503
+ let vx = vel[j], vy = vel[j + 1], vz = vel[j + 2];
504
+ const onPlate = x > -HX && x < HX && z > -HX && z < HX;
505
+ // resting grains with no velocity are re-checked every third step (the field changes slowly)
506
+ if (ground[i] && vx === 0 && vz === 0 && !nTap && !handOn && onPlate && (i + stepNo) % 3 !== 0) {
507
+ EXP[i] *= decEx;
508
+ continue;
509
+ }
510
+ if (onPlate)
511
+ sampleW(x, z, smp);
512
+ else {
513
+ smp[0] = 0;
514
+ smp[1] = 0;
515
+ smp[2] = 0;
516
+ }
517
+ const w = smp[0], gx = smp[1], gz = smp[2];
518
+ const aw = w < 0 ? -w : w;
519
+ const G = Math.min(gain * aw, 6);
520
+ if (!onPlate) {
521
+ // fell off the edge: free fall, then a fresh sprinkle from above
522
+ ground[i] = 0;
523
+ vy -= G_SIM * h;
524
+ x += vx * h;
525
+ y += vy * h;
526
+ z += vz * h;
527
+ if (y < -0.6) {
528
+ pos[j] = x;
529
+ pos[j + 1] = y;
530
+ pos[j + 2] = z;
531
+ sprinkle(i, false);
532
+ x = pos[j];
533
+ y = pos[j + 1];
534
+ z = pos[j + 2];
535
+ vx = vel[j];
536
+ vy = vel[j + 1];
537
+ vz = vel[j + 2];
538
+ if (trail)
539
+ trail.reset(i);
540
+ }
541
+ }
542
+ else if (ground[i]) {
543
+ if (G > thr[i]) {
544
+ // hop: height from the excess acceleration, a random step that grows with the vibration
545
+ const ex2 = Math.sqrt(G - thr[i]);
546
+ vy = HOP * ex2 * (0.55 + 0.8 * hr());
547
+ const ang = 6.2831853 * hr();
548
+ const st = HSTEP * ex2 * (0.3 + hr());
549
+ const sg = w >= 0 ? 1 : -1;
550
+ let dx = -DRIFT * sg * gx, dz = -DRIFT * sg * gz;
551
+ const dl = Math.hypot(dx, dz);
552
+ if (dl > 0.12) {
553
+ dx *= 0.12 / dl;
554
+ dz *= 0.12 / dl;
555
+ }
556
+ vx = Math.cos(ang) * st + dx;
557
+ vz = Math.sin(ang) * st + dz;
558
+ ground[i] = 0;
559
+ y = 0.001;
560
+ }
561
+ else {
562
+ vx *= fric;
563
+ vz *= fric;
564
+ vy = 0;
565
+ if (vx * vx + vz * vz < 1e-6) {
566
+ vx = 0;
567
+ vz = 0;
568
+ }
569
+ x += vx * h;
570
+ z += vz * h;
571
+ }
572
+ }
573
+ else {
574
+ vy -= G_SIM * h;
575
+ x += vx * h;
576
+ y += vy * h;
577
+ z += vz * h;
578
+ if (y <= 0 && x > -HX && x < HX && z > -HX && z < HX) {
579
+ y = 0;
580
+ vy = 0;
581
+ vx *= 0.25;
582
+ vz *= 0.25;
583
+ ground[i] = 1;
584
+ if (vx * vx + vz * vz < 1e-6) {
585
+ vx = 0;
586
+ vz = 0;
587
+ }
588
+ }
589
+ }
590
+ if (handOn && onPlate) {
591
+ if (fingS > 0.02) {
592
+ const dx = x - hp.x, dz = z - hp.z, d2 = dx * dx + dz * dz;
593
+ if (d2 < RF * RF && y < 0.03) {
594
+ // pushed out of the fingertip's footprint, carried along with its motion: a groove
595
+ const d = Math.sqrt(d2) + 1e-5;
596
+ x = hp.x + dx / d * RF * 1.02;
597
+ z = hp.z + dz / d * RF * 1.02;
598
+ vx = dx / d * 0.25 * fingS + fvx * 0.6;
599
+ vz = dz / d * 0.25 * fingS + fvz * 0.6;
600
+ if (hr() < 0.25) {
601
+ vy = 0.25 + 0.3 * hr();
602
+ ground[i] = 0;
603
+ y = Math.max(y, 0.001);
604
+ }
605
+ }
606
+ }
607
+ if (bowS > 0.02 && ground[i]) {
608
+ const dx = x - bx, dz = z - bz, d2 = dx * dx + dz * dz;
609
+ if (d2 < RB * RB && hr() < bowS * 0.35 * (1 - Math.sqrt(d2) / RB)) {
610
+ // grains near the bowed edge chatter: sharp hops, thrown in from the edge
611
+ const ang = 6.2831853 * hr();
612
+ vy = 0.5 + 0.6 * hr() * bowS;
613
+ vx = Math.cos(ang) * 0.2 - (ex === 0 ? Math.sign(bx) * 0.25 : 0);
614
+ vz = Math.sin(ang) * 0.2 - (ez === 0 ? Math.sign(bz) * 0.25 : 0);
615
+ ground[i] = 0;
616
+ y = 0.001;
617
+ }
618
+ }
619
+ }
620
+ // tap bending waves throw the grains they pass
621
+ if (nTap && onPlate && ground[i]) {
622
+ for (let k = 0; k < MAX_TAPS; k++) {
623
+ const age = TP[k * 4 + 2];
624
+ if (age >= 1.4 || TP[k * 4 + 3] <= 0)
625
+ continue;
626
+ const dx = x - TP[k * 4], dz = z - TP[k * 4 + 1];
627
+ const r = Math.sqrt(dx * dx + dz * dz) + 1e-4;
628
+ // the bending wave is damped within the tap's reach (pointerRadius): a local ring
629
+ const reach = 0.3 * TP[k * 4 + 3] / Math.max(S, 0.01);
630
+ const rf = 0.8 * age, wd = 0.025 + 0.04 * age;
631
+ if (rf > 1.3 * reach)
632
+ continue;
633
+ const off = r - rf;
634
+ if (off * off < wd * wd) {
635
+ const e = S * Math.exp(-(rf * rf) / (reach * reach)) * (r < 0.25 * reach ? 1.3 : 1);
636
+ if (hr() < 0.85) {
637
+ vy = Math.min(1.3, 0.9 * e * (0.6 + 0.8 * hr()));
638
+ vx = dx / r * 0.4 * e + (hr() - 0.5) * 0.12;
639
+ vz = dz / r * 0.4 * e + (hr() - 0.5) * 0.12;
640
+ ground[i] = 0;
641
+ y = Math.max(y, 0.001);
642
+ }
643
+ }
644
+ }
645
+ }
646
+ pos[j] = x;
647
+ pos[j + 1] = y;
648
+ pos[j + 2] = z;
649
+ vel[j] = vx;
650
+ vel[j + 1] = vy;
651
+ vel[j + 2] = vz;
652
+ // exposure memory of the grain's speed (trails are shaded by it)
653
+ const sp = Math.sqrt(vx * vx + vy * vy + vz * vz);
654
+ const e = EXP[i] * decEx;
655
+ EXP[i] = sp > e ? sp : e;
656
+ }
657
+ lastPeak = peak;
658
+ }
659
+ let lastPeak = 0;
660
+ let trail = null, trailKey = '';
661
+ // ---- sand heightfield -> raking-light shade per grain (render only) ----
662
+ const DC = new Float32Array(DG * DG), DS = new Float32Array(DG * DG), HS = new Float32Array(DG * DG);
663
+ const rpos = new Float32Array(n * 3);
664
+ function shadeGrains() {
665
+ DC.fill(0);
666
+ for (let i = 0; i < n; i++) {
667
+ if (!ground[i])
668
+ continue;
669
+ const gx = Math.floor((pos[i * 3] + HX) / (2 * HX) * DG), gz = Math.floor((pos[i * 3 + 2] + HX) / (2 * HX) * DG);
670
+ if (gx < 0 || gz < 0 || gx >= DG || gz >= DG)
671
+ continue;
672
+ DC[gz * DG + gx] += 1;
673
+ }
674
+ // one 3x3 blur, then height ~ sqrt(count) (a grain pile's volume spreads into its footprint)
675
+ const avg = n / (DG * DG);
676
+ for (let j = 0; j < DG; j++)
677
+ for (let i = 0; i < DG; i++) {
678
+ let s = 0, wsum = 0;
679
+ for (let b = -1; b <= 1; b++)
680
+ for (let a = -1; a <= 1; a++) {
681
+ const ii = i + a, jj = j + b;
682
+ if (ii < 0 || jj < 0 || ii >= DG || jj >= DG)
683
+ continue;
684
+ const ww = a === 0 && b === 0 ? 4 : (a === 0 || b === 0 ? 2 : 1);
685
+ s += DC[jj * DG + ii] * ww;
686
+ wsum += ww;
687
+ }
688
+ DS[j * DG + i] = Math.sqrt(s / wsum / avg);
689
+ }
690
+ const el = (u.uLightAngle.value * Math.PI) / 180;
691
+ const Lx = -0.78 * Math.cos(el), Ly = Math.sin(el), Lz = 0.62 * Math.cos(el);
692
+ const Ll = Math.hypot(Lx, Ly, Lz);
693
+ const cell = (2 * HX) / DG, HK = 0.006;
694
+ for (let j = 0; j < DG; j++)
695
+ for (let i = 0; i < DG; i++) {
696
+ const hL = DS[j * DG + Math.max(i - 1, 0)], hR = DS[j * DG + Math.min(i + 1, DG - 1)];
697
+ const hD = DS[Math.max(j - 1, 0) * DG + i], hU = DS[Math.min(j + 1, DG - 1) * DG + i];
698
+ const dx = (hR - hL) * HK / (2 * cell) * 3, dz = (hU - hD) * HK / (2 * cell) * 3;
699
+ const nl = Math.hypot(dx, 1, dz);
700
+ const ndl = (-dx * Lx + Ly - dz * Lz) / (nl * Ll);
701
+ HS[j * DG + i] = Math.max(0.12, 0.35 + 0.65 * Math.max(ndl, 0) / (Ly / Ll));
702
+ }
703
+ for (let i = 0; i < n; i++) {
704
+ const j = i * 3;
705
+ rpos[j] = pos[j];
706
+ rpos[j + 2] = pos[j + 2];
707
+ const gx = Math.floor((pos[j] + HX) / (2 * HX) * DG), gz = Math.floor((pos[j + 2] + HX) / (2 * HX) * DG);
708
+ const inside = gx >= 0 && gz >= 0 && gx < DG && gz < DG;
709
+ const hgt = inside ? DS[gz * DG + gx] * HK : 0;
710
+ if (ground[i]) {
711
+ rpos[j + 1] = hgt * (0.35 + 0.65 * pileY[i]) + 0.002;
712
+ shade[i] = inside ? Math.min(HS[gz * DG + gx], 1.1) : 1;
713
+ }
714
+ else {
715
+ rpos[j + 1] = pos[j + 1] + 0.002 + hgt * 0.5;
716
+ shade[i] = 1.15 + Math.min(pos[j + 1] * 8, 0.4);
717
+ }
718
+ }
719
+ }
720
+ const stepper = createFixedStep(H_STEP, 4);
721
+ for (let s = 0; s < 200; s++)
722
+ step(H_STEP * 2); // ~7 s of hops at a coarse step: the first figure has formed
723
+ shadeGrains();
724
+ // ---- geometry ----
725
+ const geo = new THREE.BufferGeometry();
726
+ const posAttr = new THREE.BufferAttribute(rpos, 3).setUsage(THREE.DynamicDrawUsage);
727
+ const shAttr = new THREE.BufferAttribute(shade, 1).setUsage(THREE.DynamicDrawUsage);
728
+ geo.setAttribute('position', posAttr);
729
+ geo.setAttribute('aShade', shAttr);
730
+ geo.setAttribute('aSeed', new THREE.BufferAttribute(seedA, 1));
731
+ const mat = new THREE.ShaderMaterial({
732
+ uniforms: u, vertexShader: grainVertex, fragmentShader: grainFragment,
733
+ transparent: true, depthWrite: false,
734
+ });
735
+ const grains = new THREE.Points(geo, mat);
736
+ grains.frustumCulled = false;
737
+ grains.renderOrder = 2;
738
+ attachViewH(grains, u);
739
+ const fgeo = new THREE.BufferGeometry();
740
+ const ppAttr = new THREE.BufferAttribute(PP, 3).setUsage(THREE.DynamicDrawUsage);
741
+ const paAttr = new THREE.BufferAttribute(PA, 1).setUsage(THREE.DynamicDrawUsage);
742
+ fgeo.setAttribute('position', ppAttr);
743
+ fgeo.setAttribute('aAge', paAttr);
744
+ const fmat = new THREE.ShaderMaterial({
745
+ uniforms: u, vertexShader: puffVertex, fragmentShader: puffFragment,
746
+ transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
747
+ });
748
+ const puffs = new THREE.Points(fgeo, fmat);
749
+ puffs.frustumCulled = false;
750
+ puffs.renderOrder = 3;
751
+ const object = new THREE.Group();
752
+ const proxy = createPickProxy(BOUNDS.center, BOUNDS.radius);
753
+ object.add(grains, puffs, proxy.mesh);
754
+ const anchor = new THREE.Vector3(0, 0, 0);
755
+ const up = new THREE.Vector3(0, 1, 0);
756
+ // ---- trails: grains in motion, shaded by an exposure memory of their speed ----
757
+ const trailGLSL = /* glsl */ `
758
+ uniform vec3 uColor, uHopColor;
759
+ uniform float uTrailGain;
760
+ vec3 trailShade(vec3 p, float f) {
761
+ float e = clamp(TRAIL_K * 2.2, 0.0, 1.0);
762
+ return mix(uColor, uHopColor, 0.5) * e * 0.55 * uTrailGain;
763
+ }
764
+ vec3 trailView(vec3 mv, out float mag) { mag = 1.0; return mv; }
765
+ `;
766
+ const materials = [mat, fmat];
767
+ function buildTrails(mode, share) {
768
+ const key = mode + ':' + share;
769
+ if (key === trailKey)
770
+ return;
771
+ trailKey = key;
772
+ if (trail) {
773
+ object.remove(trail.object);
774
+ trail.dispose();
775
+ trail = null;
776
+ }
777
+ materials.length = 2;
778
+ u.uTrailGain.value = TRAIL_GAIN[mode] || 1;
779
+ trail = createCpuTrail(mode, { u, n, pos: rpos, vel, k: EXP, glsl: trailGLSL, sizePx: 0.005, spacing: 0.002, sub: 8 });
780
+ if (!trail)
781
+ return;
782
+ trail.object.renderOrder = 1;
783
+ object.add(trail.object);
784
+ materials.push(...trail.materials);
785
+ }
786
+ buildTrails(c.trailMode, c.trailShare);
787
+ let shadeTick = 0;
788
+ function upload() {
789
+ if ((shadeTick++ & 1) === 0)
790
+ shadeGrains();
791
+ else {
792
+ for (let i = 0; i < n; i++) {
793
+ if (ground[i])
794
+ continue;
795
+ const j = i * 3;
796
+ rpos[j] = pos[j];
797
+ rpos[j + 1] = pos[j + 1] + 0.002;
798
+ rpos[j + 2] = pos[j + 2];
799
+ }
800
+ }
801
+ posAttr.needsUpdate = true;
802
+ shAttr.needsUpdate = true;
803
+ ppAttr.needsUpdate = true;
804
+ paAttr.needsUpdate = true;
805
+ }
806
+ return {
807
+ object,
808
+ materials,
809
+ apply(cfg) { if (cfg)
810
+ buildTrails(cfg.trailMode, cfg.trailShare); },
811
+ update(dt, camera, pointer) {
812
+ const rdt = Math.min(Math.max(dt, 0), 1 / 20);
813
+ if (camera && !inView(object, camera, BOUNDS.center, BOUNDS.radius, u.uSize.value))
814
+ return;
815
+ const d = rdt * u.uSpeed.value;
816
+ u.uTime.value += d;
817
+ if (camera && pointer)
818
+ stepPointerField(u, pointer, camera, object, u.uSize.value, rdt, anchor, up);
819
+ u.uVelScale.value = 1;
820
+ let simD = 0;
821
+ stepper.run(d, (hh) => { step(hh); simD += hh; });
822
+ if (simD > 0) {
823
+ upload();
824
+ if (trail)
825
+ trail.update(simD);
826
+ }
827
+ },
828
+ seek(t) { for (let s = 0; s < Math.round(t / H_STEP); s++)
829
+ step(H_STEP); upload(); },
830
+ dispose() {
831
+ geo.dispose();
832
+ mat.dispose();
833
+ fgeo.dispose();
834
+ fmat.dispose();
835
+ proxy.dispose();
836
+ if (trail)
837
+ trail.dispose();
838
+ },
839
+ _state: { pos, vel, ground, A, E, W, get peak() { return lastPeak; } },
840
+ };
841
+ }
842
+ /** Defaults for every knob, so an older saved config still renders (identical to the catalog entry). */
843
+ const DEFAULTS = {
844
+ intensity: 1,
845
+ speed: 1,
846
+ opacity: 0.9,
847
+ size: 1,
848
+ color: '#f1e3c2',
849
+ particleCount: 20000,
850
+ frequency: 4.63,
851
+ sweep: 0.4,
852
+ damping: 0.025,
853
+ amplitude: 1,
854
+ hopColor: '#fff3d6',
855
+ lightAngle: 22,
856
+ grainSize: 1,
857
+ seed: 1,
858
+ quality: 'medium',
859
+ pointerMode: 'bow',
860
+ pointerStrength: 1,
861
+ pointerRadius: 1,
862
+ pointerColor: '#fff4dc',
863
+ trailMode: 'streak',
864
+ trailLength: 1.6,
865
+ trailOpacity: 0.6,
866
+ trailColor: '#ffcf8a',
867
+ trailShare: 0.5,
868
+ };
869
+ /** Knobs that rebuild the simulation: they change on commit, never through the live path. */
870
+ const STRUCTURAL = ['particleCount', 'seed', 'quality'];
871
+ /** Knobs an animation (or mouse move) may drive per frame — generated from applyConfig. */
872
+ const ANIMATABLE = new Set(GENERATIVE_PARTICLES_ANIMATABLE.chladniSand.map((p) => p.value));
873
+ const ChladniSandParticles = ({ id, size, config, animations, onObjectReady, onObjectDispose }) => {
874
+ const rootRef = useRef(null);
875
+ useObjectReady(rootRef, config, onObjectReady, onObjectDispose);
876
+ const s = size > 0 ? size : 1;
877
+ const cfg = useMemo(() => ({ ...DEFAULTS, ...config }), [config]);
878
+ // uniforms outlive the objects, so keyframe animations survive a rebuild
879
+ const uniforms = useMemo(() => createUniforms(), []);
880
+ // rebuilt only when a structural knob changes (trails rebuild inside apply())
881
+ const instance = useMemo(() => build(uniforms, cfg, s), [uniforms, cfg.particleCount, cfg.seed, cfg.quality]);
882
+ useEffect(() => () => instance.dispose(), [instance]);
883
+ useLayoutEffect(() => {
884
+ applyConfig(uniforms, cfg, s);
885
+ instance.apply(cfg);
886
+ }, [instance, uniforms, cfg, s]);
887
+ // Slider drags and keyframe animations are both overrides on the committed config.
888
+ // This is the one place they are merged and pushed, so they compose instead of
889
+ // overwriting each other; the animation wins, because it is the active driver.
890
+ const keyframes = animations?.particles?.animations;
891
+ const animOverride = useRef({});
892
+ const animFrom = useRef({});
893
+ const animStart = useRef(0);
894
+ // Editing a keyframe restarts its cycle rather than dropping the user mid-way.
895
+ useEffect(() => { animStart.current = performance.now(); animFrom.current = {}; }, [keyframes]);
896
+ const push = useCallback((overrides) => {
897
+ const merged = { ...cfg, ...overrides, ...animOverride.current };
898
+ const z = merged.size;
899
+ applyConfig(uniforms, merged, z && z > 0 ? z : s);
900
+ }, [cfg, uniforms, s]);
901
+ const live = useConfigLiveHandle(rootRef, id, 'particles', cfg, STRUCTURAL, push);
902
+ const pointer = usePointerField();
903
+ useFrame((state, delta) => {
904
+ instance.update(Math.min(delta, 0.1), state.camera, pointer);
905
+ if (keyframes?.length) {
906
+ applyConfigKeyframeAnimations(keyframes, animFrom.current, cfg, animOverride.current, performance.now() - animStart.current, ANIMATABLE);
907
+ push(live.current);
908
+ }
909
+ });
910
+ return (_jsx("group", { ref: rootRef, children: _jsx("primitive", { object: instance.object }) }));
911
+ };
912
+ export default ChladniSandParticles;