Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions packages/pool/__tests__/pool-field.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { presets } from '@wavegrid/layout/client';
import {
cannonPoints,
CURRENT_N,
ROTATE_LAP_SECONDS,
DEFAULT_POOL_SETTINGS,
type Hsb,
OutputSmoother,
Expand Down Expand Up @@ -402,3 +403,46 @@ describe('the current', () => {
});
});
});

describe('rotate', () => {
const posOfFirst = (field: PoolField) => {
let first: { x: number; y: number } | null = null;
field.forEachSource((s) => {
if (!first) first = { x: s.x, y: s.y };
});
return first!;
};

it('turns a held spot about the centre at one lap per ROTATE_LAP_SECONDS, eased in', () => {
const field = new PoolField({ ...DEFAULT_POOL_SETTINGS, hold: true, motion: 0, rotate: 1 });
field.pointerDown(1, 0.8, 0.5, COLOR);
field.step(DT);
field.pointerUp(1);
const p0 = posOfFirst(field);
run(field, ROTATE_LAP_SECONDS / 4);
const p1 = posOfFirst(field);
const a0 = Math.atan2(p0.y - 0.5, p0.x - 0.5);
const a1 = Math.atan2(p1.y - 0.5, p1.x - 0.5);
const turned = ((a1 - a0 + 3 * Math.PI) % (2 * Math.PI)) - Math.PI;
// A quarter lap, less the ~3 s ease-in; clockwise on screen (y down).
expect(turned).toBeGreaterThan(Math.PI / 2 - 1.2);
expect(turned).toBeLessThan(Math.PI / 2);
expect(Math.hypot(p1.x - 0.5, p1.y - 0.5)).toBeCloseTo(0.3, 2);
});

it('negative rotate turns the other way; 0 leaves a still pool still', () => {
const ccw = new PoolField({ ...DEFAULT_POOL_SETTINGS, hold: true, motion: 0, rotate: -0.5 });
ccw.pointerDown(1, 0.8, 0.5, COLOR);
ccw.step(DT);
ccw.pointerUp(1);
run(ccw, 5);
expect(posOfFirst(ccw).y).toBeLessThan(0.49);

const still = new PoolField({ ...DEFAULT_POOL_SETTINGS, hold: true, motion: 0, rotate: 0 });
still.pointerDown(1, 0.8, 0.5, COLOR);
still.step(DT);
still.pointerUp(1);
run(still, 5);
expect(posOfFirst(still).y).toBeCloseTo(0.5, 3);
});
});
33 changes: 32 additions & 1 deletion packages/pool/src/pool-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,20 @@ export interface PoolSettings {
* `persistence`.
*/
hold?: boolean;
/**
* -1..1 — a constant, slow turn of the whole pool about the layout centre,
* under whatever else is happening. 0 is still; sign is direction.
*/
rotate?: number;
}

export const DEFAULT_POOL_SETTINGS: PoolSettings = {
mode: 'flow',
motion: 0.35,
spread: 0.5,
persistence: 0.55,
hold: false
hold: false,
rotate: 0
};

export interface PoolColor {
Expand Down Expand Up @@ -130,6 +136,10 @@ const SPIRAL_CENTRE_TAU = 1.6;
const SPIRAL_SPIN_TAU = 2.2;
/** A released stroke dissolves on this clock. */
const RELEASE_TAU = 0.9;
/** Rotate at full deflection: one lap of the pool in this many seconds. */
export const ROTATE_LAP_SECONDS = 20;
/** Moving the Rotate slider eases the turn in on this clock. */
const ROTATE_TAU = 3;
/** A new source swells in on this clock, so a tap blooms rather than pops. */
const BLOOM_TAU = 0.4;
/** A held blob never spreads wider than this, so a held field keeps its shape. */
Expand All @@ -152,6 +162,11 @@ export function spreadSigma(s: number): number {
}

/** Motion 0..1 → spiral spin in radians per second (a lap takes 20s to 2min). */
/** Angular speed (rad/s) for a Rotate setting of -1..1. */
export function rotateOmega(r: number): number {
return (clamp(r, -1, 1) * 2 * Math.PI) / ROTATE_LAP_SECONDS;
}

export function spiralOmega(m: number): number {
return lerp(0.05, 0.32, clamp(m, 0, 1));
}
Expand All @@ -172,6 +187,8 @@ export class PoolField {
private stir = 0;
/** How the water remembers being stirred; sources ride it. */
private current = new Current();
/** The constant turn, eased toward the slider. */
private rotOmega = 0;
/** Bumped by reset(), so whoever smooths the output knows to drop its state too. */
generation = 0;

Expand Down Expand Up @@ -352,6 +369,18 @@ export class PoolField {
this.spiral.omega += (0 - this.spiral.omega) * lagStep(dt, SPIRAL_SPIN_TAU);
}

this.rotOmega += (rotateOmega(this.settings.rotate ?? 0) - this.rotOmega) * lagStep(dt, ROTATE_TAU);
const rot = this.rotOmega * dt;
const cosR = Math.cos(rot);
const sinR = Math.sin(rot);
const carousel = (s: { x: number; y: number }) => {
if (Math.abs(rot) < 1e-7) return;
const rx = s.x - 0.5;
const ry = s.y - 0.5;
s.x = 0.5 + rx * cosR - ry * sinR;
s.y = 0.5 + rx * sinR + ry * cosR;
};

// 3. Sources: drift, spread, turn, dissolve.
const hold = this.settings.hold === true;
const tau = persistenceSeconds(this.settings.persistence);
Expand Down Expand Up @@ -382,6 +411,7 @@ export class PoolField {
s.ring += ringGrow;
// A ring thins as it widens so the total light stays about the same.
s.energy *= Math.exp(-ringGrow * 2.5);
carousel(s);
continue;
}
const c = this.current.at(s.x, s.y);
Expand All @@ -397,6 +427,7 @@ export class PoolField {
s.x = cx + rx * cosT - ry * sinT;
s.y = cy + rx * sinT + ry * cosT;
}
carousel(s);
}

this.sources = this.sources.filter((s) => (s.energy > 0.004 || s.target > 0.004) && (s.kind === 'blob' || s.ring < 1.6));
Expand Down
10 changes: 8 additions & 2 deletions packages/server/__tests__/pool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ interface PoolMsg {
active: boolean;
touches: number;
sources: Array<{ x: number; y: number; energy: number }>;
settings: { mode: string; motion: number; spread: number; persistence: number; hold?: boolean };
settings: { mode: string; motion: number; spread: number; persistence: number; hold?: boolean; rotate?: number };
}

interface Client {
Expand Down Expand Up @@ -86,7 +86,7 @@ describe('server-owned pool', () => {

beforeEach(async () => {
handle.send({ type: 'clear' });
handle.send({ type: 'pool_settings', mode: 'flow', motion: 0.35, spread: 0.5, persistence: 0.55, hold: false });
handle.send({ type: 'pool_settings', mode: 'flow', motion: 0.35, spread: 0.5, persistence: 0.55, hold: false, rotate: 0 });
handle.send({ type: 'smoothness', value: 0.3 });
handle.send({ type: 'attack', value: 1 });
await wait(50);
Expand Down Expand Up @@ -225,6 +225,12 @@ describe('server-owned pool', () => {
a.send({ type: 'pool_settings', hold: true });
await wait(80);
expect(b.pool?.settings.hold).toBe(true);
a.send({ type: 'pool_settings', rotate: -3 });
await wait(80);
expect(b.pool?.settings.rotate).toBe(-1);
a.send({ type: 'pool_settings', rotate: 'fast' });
await wait(80);
expect(b.pool?.settings.rotate).toBe(-1);
a.ws.close();
b.ws.close();
});
Expand Down
6 changes: 5 additions & 1 deletion packages/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -831,7 +831,11 @@ function handleMessage(msg: any, ws?: WebSocket) {
motion: num(msg.motion, s.motion),
spread: num(msg.spread, s.spread),
persistence: num(msg.persistence, s.persistence),
hold: typeof msg.hold === 'boolean' ? msg.hold : s.hold === true
hold: typeof msg.hold === 'boolean' ? msg.hold : s.hold === true,
rotate:
typeof msg.rotate === 'number' && Number.isFinite(msg.rotate)
? Math.max(-1, Math.min(1, msg.rotate))
: (s.rotate ?? 0)
};
broadcastPool();
scheduleSave();
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -810,7 +810,8 @@ export default function Home() {
prev.motion === poolServerSettings.motion &&
prev.spread === poolServerSettings.spread &&
prev.persistence === poolServerSettings.persistence &&
(prev.hold === true) === (poolServerSettings.hold === true)
(prev.hold === true) === (poolServerSettings.hold === true) &&
(prev.rotate ?? 0) === (poolServerSettings.rotate ?? 0)
? prev
: poolServerSettings
);
Expand Down
27 changes: 26 additions & 1 deletion packages/ui/src/components/pool-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function PoolTab({
onSettings({ ...settings, [key]: value });
const mode = MODES.find((m) => m.key === settings.mode) ?? MODES[0];
const hold = settings.hold === true;
const rotate = settings.rotate ?? 0;

return (
<div className="space-y-3">
Expand Down Expand Up @@ -252,8 +253,32 @@ export function PoolTab({
</span>
</div>
))}
<div className="flex items-center gap-3">
<span
className="text-sm font-medium shrink-0"
style={{ color: '#888898', minWidth: 48 }}
>
Rotate
</span>
<input
type="range"
className="flex-1"
min={-100}
max={100}
value={Math.round(rotate * 100)}
onChange={(e) => set('rotate', Number(e.target.value) / 100)}
onDoubleClick={() => set('rotate', 0)}
/>
<span
className="text-sm font-mono shrink-0"
style={{ color: '#888898', minWidth: 28, textAlign: 'right' }}
>
{rotate === 0 ? 'off' : `${rotate > 0 ? '↻' : '↺'}${Math.round(Math.abs(rotate) * 100)}`}
</span>
</div>
<p className="text-sm" style={{ color: 'rgba(136,136,152,0.5)' }}>
Intensity and fade are the master sliders above; Clear up top
Rotate turns the whole pool slowly, under everything else; centre
is off. Intensity and fade are the master sliders above; Clear up top
dissolves too.
</p>
</ControlGroup>
Expand Down
Loading