From b8858474e4a2177a4782776c98a7450f6e97723b Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 17 Sep 2026 04:47:07 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(pool):=20the=20current=20=E2=80=94=20d?= =?UTF-8?q?rags=20stir=20a=20slow=20persistent=20flow=20that=20carries=20t?= =?UTF-8?q?he=20light?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/pool/__tests__/pool-field.test.ts | 80 +++++++++ packages/pool/src/current.ts | 194 +++++++++++++++++++++ packages/pool/src/index.ts | 1 + packages/pool/src/pool-field.ts | 31 +++- packages/ui/src/components/pool-canvas.tsx | 27 ++- packages/ui/src/components/pool-tab.tsx | 30 ++-- packages/ui/src/lib/socket-state.ts | 3 +- 7 files changed, 346 insertions(+), 20 deletions(-) create mode 100644 packages/pool/src/current.ts diff --git a/packages/pool/__tests__/pool-field.test.ts b/packages/pool/__tests__/pool-field.test.ts index 1289d6a..6b9a945 100644 --- a/packages/pool/__tests__/pool-field.test.ts +++ b/packages/pool/__tests__/pool-field.test.ts @@ -2,6 +2,7 @@ import { presets } from '@wavegrid/layout/client'; import { cannonPoints, + CURRENT_N, DEFAULT_POOL_SETTINGS, type Hsb, OutputSmoother, @@ -322,3 +323,82 @@ describe('OutputSmoother', () => { expect(quantize({ h: 359.6, s: 49.5, b: 0.4 })).toEqual({ h: 0, s: 50, b: 0 }); }); }); + +describe('the current', () => { + /** Drag a finger round the centre for `seconds`. */ + function circle(field: PoolField, seconds: number, radius = 0.3) { + field.pointerDown(1, 0.5 + radius, 0.5, COLOR); + for (let t = 0; t < seconds; t += DT) { + const a = t * 2; + field.pointerMove(1, 0.5 + radius * Math.cos(a), 0.5 + radius * Math.sin(a)); + field.step(DT); + } + field.pointerUp(1); + } + const angleOfFirst = (field: PoolField) => { + let first: { x: number; y: number } | null = null; + field.forEachSource((s) => { + if (!first) first = { x: s.x, y: s.y }; + }); + return first ? Math.atan2(first!.y - 0.5, first!.x - 0.5) : NaN; + }; + + it('a circling drag leaves a slow swirl that keeps carrying the light after release', () => { + const field = new PoolField({ ...DEFAULT_POOL_SETTINGS, hold: true }); + circle(field, 3); + expect(field.currentActive).toBe(true); + const c = field.currentAt(0.8, 0.5); + // Anticlockwise drag (in screen coords) → the water at the right moves down-screen. + expect(c.vy).toBeGreaterThan(0.005); + expect(Math.hypot(c.vx, c.vy)).toBeLessThan(0.1); + const a0 = angleOfFirst(field); + run(field, 20); + const turned = ((angleOfFirst(field) - a0 + 3 * Math.PI) % (2 * Math.PI)) - Math.PI; + // Carried round, slowly: a clear fraction of a lap in 20s, not a whole one. + expect(turned).toBeGreaterThan(0.4); + expect(turned).toBeLessThan(Math.PI); + expect(maxB(field.sampleAll(gracePoints))).toBeGreaterThan(20); + }); + + it('the water settles over minutes on its own and within seconds on release()', () => { + const slow = new PoolField(); + circle(slow, 3); + const s0 = Math.hypot(slow.currentAt(0.8, 0.5).vx, slow.currentAt(0.8, 0.5).vy); + run(slow, 30); + const s30 = Math.hypot(slow.currentAt(0.8, 0.5).vx, slow.currentAt(0.8, 0.5).vy); + expect(s30).toBeLessThan(s0); + expect(s30).toBeGreaterThan(s0 * 0.25); + + const released = new PoolField(); + circle(released, 3); + released.release(); + run(released, 12); + const c = released.currentAt(0.8, 0.5); + expect(Math.hypot(c.vx, c.vy)).toBeLessThan(s0 * 0.02); + }); + + it('reset() stills the water at once; a still pool sends no current', () => { + const field = new PoolField(); + expect(field.snapshot().current).toEqual([]); + circle(field, 2); + expect(field.snapshot().current.length).toBe(CURRENT_N * CURRENT_N * 2); + field.reset(); + expect(field.currentActive).toBe(false); + expect(field.currentAt(0.8, 0.5)).toEqual({ vx: 0, vy: 0 }); + }); + + it('sources carried by the current stay inside the pool', () => { + const field = new PoolField({ ...DEFAULT_POOL_SETTINGS, hold: true, motion: 1 }); + field.pointerDown(1, 0.1, 0.5, COLOR); + for (let t = 0; t < 2; t += DT) { + field.pointerMove(1, 0.1 + t * 0.4, 0.5); + field.step(DT); + } + field.pointerUp(1); + run(field, 30); + field.forEachSource((s) => { + expect(s.x).toBeGreaterThanOrEqual(-0.05); + expect(s.x).toBeLessThanOrEqual(1.05); + }); + }); +}); diff --git a/packages/pool/src/current.ts b/packages/pool/src/current.ts new file mode 100644 index 0000000..5bd02ed --- /dev/null +++ b/packages/pool/src/current.ts @@ -0,0 +1,194 @@ +/** + * The current: the water's memory of how it was stirred. + * + * A coarse velocity grid over the 0..1 square. Fingers push on it as they + * drag; the light sources are carried by it afterwards. A tiny pressure solve + * keeps the flow incompressible, which is what turns a push into a swirl — + * pull one side of the pool and the other side comes round to fill the gap — + * instead of shoving everything into a corner. It diffuses so neighbouring + * pushes agree, and it decays over minutes, so the pool keeps moving long + * after the hands are gone and eventually comes to rest. + */ + +export const CURRENT_N = 16; +/** Current speeds are capped here (normalized units per second): a lap of the pool takes tens of seconds. */ +export const CURRENT_MAX_SPEED = 0.08; +/** How long a swirl takes to fall to 1/e with nobody touching it. */ +const CURRENT_TAU = 90; +/** How fast neighbouring cells pull each other's flow into agreement. */ +const CURRENT_DIFFUSION = 0.6; +const PROJECT_ITERATIONS = 10; + +const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); +const round3 = (v: number) => Math.round(v * 1000) / 1000; + +export class Current { + readonly n = CURRENT_N; + private u = new Float32Array(CURRENT_N * CURRENT_N); + private v = new Float32Array(CURRENT_N * CURRENT_N); + private scratch = new Float32Array(CURRENT_N * CURRENT_N); + private pressure = new Float32Array(CURRENT_N * CURRENT_N); + private divergence = new Float32Array(CURRENT_N * CURRENT_N); + /** Time constant the flow currently dies on; release() shortens it. */ + private tau = CURRENT_TAU; + private dirty = false; + + /** Push the water near (x, y) toward moving at (vx, vy). `radius` in normalized units. */ + push(x: number, y: number, vx: number, vy: number, radius: number, dt: number, maxSpeed = CURRENT_MAX_SPEED): void { + const speed = Math.hypot(vx, vy); + if (speed < 1e-6) return; + // A finger moves far faster than the water ever should: aim the current + // along the finger, at a slow fraction of its speed, capped. + const want = Math.min(maxSpeed, speed * 0.35); + const tx = (vx / speed) * want; + const ty = (vy / speed) * want; + const n = this.n; + const r2 = radius * radius; + const gain = 1 - Math.exp(-dt / 0.25); + const i0 = clamp(Math.floor((x - radius) * n), 0, n - 1); + const i1 = clamp(Math.ceil((x + radius) * n), 0, n - 1); + const j0 = clamp(Math.floor((y - radius) * n), 0, n - 1); + const j1 = clamp(Math.ceil((y + radius) * n), 0, n - 1); + for (let j = j0; j <= j1; j++) { + const cy = (j + 0.5) / n; + for (let i = i0; i <= i1; i++) { + const cx = (i + 0.5) / n; + const d2 = (cx - x) * (cx - x) + (cy - y) * (cy - y); + if (d2 > r2 * 4) continue; + const w = Math.exp((-0.5 * d2) / r2) * gain; + const k = j * n + i; + this.u[k] += (tx - this.u[k]) * w; + this.v[k] += (ty - this.v[k]) * w; + } + } + this.tau = CURRENT_TAU; + this.dirty = true; + } + + /** Advance: diffuse, keep incompressible, decay. */ + step(dt: number): void { + if (!this.dirty) return; + const n = this.n; + const { u, v, scratch } = this; + + // Diffuse: each cell eases toward the mean of its neighbours. + const a = Math.min(0.24, CURRENT_DIFFUSION * dt); + for (const f of [u, v]) { + for (let j = 0; j < n; j++) { + for (let i = 0; i < n; i++) { + const k = j * n + i; + const l = f[j * n + Math.max(0, i - 1)]; + const r = f[j * n + Math.min(n - 1, i + 1)]; + const up = f[Math.max(0, j - 1) * n + i]; + const dn = f[Math.min(n - 1, j + 1) * n + i]; + scratch[k] = f[k] + a * ((l + r + up + dn) * 0.25 - f[k]); + } + } + f.set(scratch); + } + + this.walls(); + this.project(); + + // Decay, and notice when the water has come to rest. + const decay = Math.exp(-dt / this.tau); + let energy = 0; + for (let k = 0; k < u.length; k++) { + u[k] *= decay; + v[k] *= decay; + energy += u[k] * u[k] + v[k] * v[k]; + } + if (energy < 1e-7) { + u.fill(0); + v.fill(0); + this.dirty = false; + } + } + + /** Flow at a point, bilinear. */ + at(x: number, y: number): { vx: number; vy: number } { + if (!this.dirty) return { vx: 0, vy: 0 }; + const n = this.n; + const fx = clamp(x * n - 0.5, 0, n - 1); + const fy = clamp(y * n - 0.5, 0, n - 1); + const i = Math.min(n - 2, Math.floor(fx)); + const j = Math.min(n - 2, Math.floor(fy)); + const sx = fx - i; + const sy = fy - j; + const k00 = j * n + i; + const k10 = k00 + 1; + const k01 = k00 + n; + const k11 = k01 + 1; + const lerp2 = (f: Float32Array) => + (f[k00] * (1 - sx) + f[k10] * sx) * (1 - sy) + (f[k01] * (1 - sx) + f[k11] * sx) * sy; + return { vx: lerp2(this.u), vy: lerp2(this.v) }; + } + + /** Still water. */ + get active(): boolean { + return this.dirty; + } + + /** Let the swirl die over a few seconds rather than minutes. */ + release(): void { + this.tau = 2.5; + } + + reset(): void { + this.u.fill(0); + this.v.fill(0); + this.tau = CURRENT_TAU; + this.dirty = false; + } + + /** Interleaved [u, v] per cell, row-major, rounded for the wire; empty when still. */ + snapshot(): number[] { + if (!this.dirty) return []; + const out: number[] = new Array(this.u.length * 2); + for (let k = 0; k < this.u.length; k++) { + out[2 * k] = round3(this.u[k]); + out[2 * k + 1] = round3(this.v[k]); + } + return out; + } + + /** Nothing flows through the edge of the pool. */ + private walls(): void { + const n = this.n; + for (let j = 0; j < n; j++) { + this.u[j * n] = 0; + this.u[j * n + n - 1] = 0; + } + for (let i = 0; i < n; i++) { + this.v[i] = 0; + this.v[(n - 1) * n + i] = 0; + } + } + + /** Remove divergence: a few Gauss–Seidel sweeps for pressure, then subtract its gradient. */ + private project(): void { + const n = this.n; + const { u, v, pressure: p, divergence: div } = this; + const idx = (i: number, j: number) => clamp(j, 0, n - 1) * n + clamp(i, 0, n - 1); + for (let j = 0; j < n; j++) { + for (let i = 0; i < n; i++) { + div[j * n + i] = 0.5 * (u[idx(i + 1, j)] - u[idx(i - 1, j)] + v[idx(i, j + 1)] - v[idx(i, j - 1)]); + } + } + p.fill(0); + for (let it = 0; it < PROJECT_ITERATIONS; it++) { + for (let j = 0; j < n; j++) { + for (let i = 0; i < n; i++) { + p[j * n + i] = (p[idx(i - 1, j)] + p[idx(i + 1, j)] + p[idx(i, j - 1)] + p[idx(i, j + 1)] - div[j * n + i]) * 0.25; + } + } + } + for (let j = 0; j < n; j++) { + for (let i = 0; i < n; i++) { + u[j * n + i] -= 0.5 * (p[idx(i + 1, j)] - p[idx(i - 1, j)]); + v[j * n + i] -= 0.5 * (p[idx(i, j + 1)] - p[idx(i, j - 1)]); + } + } + this.walls(); + } +} diff --git a/packages/pool/src/index.ts b/packages/pool/src/index.ts index 06e07dd..ec1f1f5 100644 --- a/packages/pool/src/index.ts +++ b/packages/pool/src/index.ts @@ -1 +1,2 @@ export * from './pool-field'; +export * from './current'; diff --git a/packages/pool/src/pool-field.ts b/packages/pool/src/pool-field.ts index b5ee39b..6e4fef5 100644 --- a/packages/pool/src/pool-field.ts +++ b/packages/pool/src/pool-field.ts @@ -17,6 +17,8 @@ * hue and saturation are the energy-weighted mix of whatever is glowing there. */ +import { Current, CURRENT_MAX_SPEED, CURRENT_N } from './current'; + export type PoolMode = 'flow' | 'spiral' | 'droplets'; export interface PoolSettings { @@ -98,8 +100,12 @@ export interface PoolSnapshot { settings: PoolSettings; sources: SourceSnapshot[]; spiral: { cx: number; cy: number; omega: number }; + /** The current, CURRENT_N² cells of [u, v], row-major; empty when the water is still. */ + current: number[]; } +export { CURRENT_N }; + interface Touch { /** Where the finger actually is. */ tx: number; @@ -164,6 +170,8 @@ export class PoolField { private spiral = { cx: 0.5, cy: 0.5, omega: 0, targetCx: 0.5, targetCy: 0.5, targetOmega: 0 }; /** Total smoothed finger speed — the field brightens a touch when it is stirred. */ private stir = 0; + /** How the water remembers being stirred; sources ride it. */ + private current = new Current(); /** Bumped by reset(), so whoever smooths the output knows to drop its state too. */ generation = 0; @@ -230,6 +238,17 @@ export class PoolField { s.releasing = true; s.target = 0; } + this.current.release(); + } + + /** Whether the water is still moving on its own. */ + get currentActive(): boolean { + return this.current.active; + } + + /** Flow at a point in normalized units per second. */ + currentAt(x: number, y: number): { vx: number; vy: number } { + return this.current.at(x, y); } /** Drop everything at once. Only for leaving the tab; the show uses release(). */ @@ -240,6 +259,7 @@ export class PoolField { this.spiral.omega = 0; this.spiral.targetOmega = 0; this.stir = 0; + this.current.reset(); } /** Advance the field by `dt` seconds. */ @@ -253,6 +273,7 @@ export class PoolField { // 1. Fingers: chase the raw position, measure velocity, deposit along the way. const k = lagStep(dt, INPUT_TAU); + const currentSpeed = lerp(0.3, 1, this.settings.motion) * CURRENT_MAX_SPEED; let stirTarget = 0; let sumX = 0; let sumY = 0; @@ -269,6 +290,8 @@ export class PoolField { t.age += dt; t.sinceDeposit += dt; stirTarget += Math.hypot(t.vx, t.vy); + // Every drag stirs the water, whatever it deposits. + this.current.push(t.x, t.y, t.vx, t.vy, sigma * 1.5, dt, currentSpeed); sumX += t.x; sumY += t.y; @@ -304,6 +327,7 @@ export class PoolField { } } this.stir = lerp(this.stir, stirTarget, lagStep(dt, 0.5)); + this.current.step(dt); // 2. Spiral: centre is the fingers' centroid, spin follows their circling. if (mode === 'spiral') { @@ -360,8 +384,9 @@ export class PoolField { s.energy *= Math.exp(-ringGrow * 2.5); continue; } - s.x += s.vx * dt; - s.y += s.vy * dt; + const c = this.current.at(s.x, s.y); + s.x = clamp(s.x + (s.vx + c.vx) * dt, -0.05, 1.05); + s.y = clamp(s.y + (s.vy + c.vy) * dt, -0.05, 1.05); s.vx -= s.vx * dragK; s.vy -= s.vy * dragK; s.sigma += diffusion * dt; @@ -438,7 +463,7 @@ export class PoolField { kind: s.kind }); } - return { settings: { ...this.settings }, sources, spiral: this.spiralState }; + return { settings: { ...this.settings }, sources, spiral: this.spiralState, current: this.current.snapshot() }; } private deposit(x: number, y: number, vx: number, vy: number, color: PoolColor, kind: Source['kind']): void { diff --git a/packages/ui/src/components/pool-canvas.tsx b/packages/ui/src/components/pool-canvas.tsx index 5171bd3..36be135 100644 --- a/packages/ui/src/components/pool-canvas.tsx +++ b/packages/ui/src/components/pool-canvas.tsx @@ -1,4 +1,4 @@ -import { cannonPoints, type PoolColor } from '@wavegrid/pool'; +import { cannonPoints, CURRENT_N, type PoolColor } from '@wavegrid/pool'; import { useCallback, useEffect, useRef } from 'react'; import type { CannonColor, Orientation, PoolState } from '@/lib/socket-state'; @@ -173,6 +173,31 @@ export function PoolCanvas({ } } + // The current: a short streak per cell, so you can see how the water is + // moving where nothing glows yet. + const cur = showField && field?.active ? field.current : []; + if (cur.length >= CURRENT_N * CURRENT_N * 2) { + ctx.lineWidth = 1; + ctx.lineCap = 'round'; + for (let j = 0; j < CURRENT_N; j++) { + for (let i = 0; i < CURRENT_N; i++) { + const k = (j * CURRENT_N + i) * 2; + const vx = cur[k]; + const vy = cur[k + 1]; + const speed = Math.hypot(vx, vy); + if (speed < 0.002) continue; + const cx = X((i + 0.5) / CURRENT_N); + const cy = X((j + 0.5) / CURRENT_N); + const len = S(Math.min(0.045, speed * 0.6)); + ctx.strokeStyle = `rgba(255,255,255,${Math.min(0.22, speed * 3)})`; + ctx.beginPath(); + ctx.moveTo(cx - (vx / speed) * len * 0.5, cy - (vy / speed) * len * 0.5); + ctx.lineTo(cx + (vx / speed) * len * 0.5, cy + (vy / speed) * len * 0.5); + ctx.stroke(); + } + } + } + // Cannons: a ring marker at every configured position, filled with the // value the server holds for it — this is exactly what the lasers do. const points = pointsRef.current; diff --git a/packages/ui/src/components/pool-tab.tsx b/packages/ui/src/components/pool-tab.tsx index f5810e3..55348cd 100644 --- a/packages/ui/src/components/pool-tab.tsx +++ b/packages/ui/src/components/pool-tab.tsx @@ -7,18 +7,18 @@ const MODES: { key: PoolMode; label: string; hint: string }[] = [ { key: 'flow', label: 'Flow', - hint: 'Soft strokes that drift, blend and dissolve.', + hint: 'Soft strokes that drift, blend and dissolve.' }, { key: 'spiral', label: 'Spiral', - hint: 'A slowly turning field. Circle to set the spin; hands off and it keeps turning.', + hint: 'A slowly turning field. Circle to set the spin; hands off and it keeps turning.' }, { key: 'droplets', label: 'Droplets', - hint: 'Slow rings that widen and thin from wherever you touch.', - }, + hint: 'Slow rings that widen and thin from wherever you touch.' + } ]; interface PoolTabProps { @@ -53,7 +53,7 @@ export function PoolTab({ onBeamsOnly, onRelease, onStop, - compact = false, + compact = false }: PoolTabProps) { const set = (key: K, value: PoolSettings[K]) => onSettings({ ...settings, [key]: value }); @@ -104,7 +104,7 @@ export function PoolTab({ border: active ? '1px solid #4a7cff' : '1px solid #1a1a25', color: active ? '#4a7cff' : '#888898', fontSize: 14, - fontWeight: 600, + fontWeight: 600 }} > {m.label} @@ -129,7 +129,7 @@ export function PoolTab({ border: beamsOnly ? '1px solid #4a7cff' : '1px solid #1a1a25', color: beamsOnly ? '#4a7cff' : '#888898', fontSize: 14, - fontWeight: 600, + fontWeight: 600 }} > Beams only @@ -146,7 +146,7 @@ export function PoolTab({ border: '1px solid #1a1a25', color: '#888898', fontSize: 14, - fontWeight: 600, + fontWeight: 600 }} > Dissolve @@ -162,7 +162,7 @@ export function PoolTab({ border: '1px solid rgba(255,80,80,0.4)', color: '#ff6b6b', fontSize: 14, - fontWeight: 600, + fontWeight: 600 }} > Blackout @@ -176,13 +176,13 @@ export function PoolTab({ { hold: false, label: 'Fade', - hint: 'Strokes dissolve on Linger.', + hint: 'Strokes dissolve on Linger.' }, { hold: true, label: 'Hold', - hint: 'Strokes stay, like Paint, until Dissolve or Blackout.', - }, + hint: 'Strokes stay, like Paint, until Dissolve or Blackout.' + } ].map((o) => { const active = o.hold === hold; return ( @@ -198,7 +198,7 @@ export function PoolTab({ border: active ? '1px solid #4a7cff' : '1px solid #1a1a25', color: active ? '#4a7cff' : '#888898', fontSize: 14, - fontWeight: 600, + fontWeight: 600 }} > {o.label} @@ -219,8 +219,8 @@ export function PoolTab({ { label: 'Linger', key: 'persistence' as const, - val: settings.persistence, - }, + val: settings.persistence + } ].map((s) => (
Date: Thu, 17 Sep 2026 04:51:37 +0000 Subject: [PATCH 2/2] fix(pool): send the current at ~4 Hz, not every pool frame; UI keeps the last one --- packages/server/src/server.ts | 14 +++++++++----- packages/ui/src/lib/socket-state.ts | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 8ebf564..34c648c 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -217,12 +217,16 @@ function activatePool() { scheduleSave(); } -function poolPayload(): string { - return JSON.stringify({ type: 'pool', active: poolActive, touches: pool.touchCount, ...pool.snapshot() }); +/** The current changes over seconds, so it rides along only every Nth pool frame; UIs keep the last one. */ +const POOL_CURRENT_EVERY = 4; + +function poolPayload(withCurrent = true): string { + const { current, ...rest } = pool.snapshot(); + return JSON.stringify({ type: 'pool', active: poolActive, touches: pool.touchCount, ...rest, ...(withCurrent ? { current } : {}) }); } -function broadcastPool() { - fanoutLossy(wss.clients, poolPayload(), dropClient); +function broadcastPool(withCurrent = true) { + fanoutLossy(wss.clients, poolPayload(withCurrent), dropClient); } /** One 60 fps step: run the field, sample the fixtures, write grid targets. */ @@ -248,7 +252,7 @@ function tickPool(dt: number) { broadcastCommand({ action: 'paint', cells }); framesSinceLastCommand = 0; } - broadcastPool(); + broadcastPool((poolFrame / 4) % POOL_CURRENT_EVERY === 0); } let playlistCurrentStep = 0; const patternEngine = new ServerPatternEngine(layout); diff --git a/packages/ui/src/lib/socket-state.ts b/packages/ui/src/lib/socket-state.ts index 407b018..0a0448c 100644 --- a/packages/ui/src/lib/socket-state.ts +++ b/packages/ui/src/lib/socket-state.ts @@ -130,7 +130,7 @@ export function applySocketMessage( settings: message.settings as PoolState['settings'], sources: message.sources as PoolState['sources'], spiral: (message.spiral as PoolState['spiral']) ?? { cx: 0.5, cy: 0.5, omega: 0 }, - current: Array.isArray(message.current) ? (message.current as number[]) : [] + current: Array.isArray(message.current) ? (message.current as number[]) : (snapshot.pool?.current ?? []) }, lastMessageAt: now };