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
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

**Wavegrid** is a modular, configuration-driven laser controller for arrays of Laser Space Cannons. It includes a grid state server, an artist-facing creative canvas, and OSC output adapters for BEYOND and FB4 hardware.

Layouts are presets, not code: `grid-7x7`, `grid-7x2`, `ring-6`, `ring-25-filled`, or any custom shape. Everything — projects, config, secrets, users, state, logs — lives in one centralized store (`~/.wavegrid`), managed entirely through the CLI.
Layouts are configuration, not code: `grid-7x7`, `grid-7x2`, `ring-6`, `ring-25-hollow`, or any custom shape. Everything — projects, config, secrets, users, state, logs — lives in one centralized store (`~/.wavegrid`), managed entirely through the CLI.

## Running a Show (operators)

Expand Down Expand Up @@ -54,7 +54,7 @@ pnpm build
|---------|------|-------------|
| `packages/server` | `@wavegrid/server` | Grid state engine and master controller UI |
| `packages/ui` | `@wavegrid/ui` | Artist UI — Paint, Gradient, Drops, Motion, Scenes, Animations, Flags, Brightness, Audio |
| `packages/layout` | `@wavegrid/layout` | Layout model — presets, fixture generators (grid/ring/filledRing), config resolution |
| `packages/layout` | `@wavegrid/layout` | Layout model — presets, fixture generators (grid/ring/rings/filledRing), config resolution |
| `packages/settings` | `@wavegrid/settings` | Centralized appstash store — projects, secrets, users, state, logs |
| `packages/doctor` | `@wavegrid/doctor` | Diagnostics as data — the checks behind `wavegrid doctor` and the desktop Status screen |
| `packages/cli` | `@wavegrid/cli` | `wavegrid` CLI — projects, settings, start, doctor |
Expand Down Expand Up @@ -104,12 +104,18 @@ Operators don't run these — they use `wavegrid start` (see [Running a Show](#r

## Layouts

The physical arrangement is a **layout preset** stored in the project — never code. Built-in presets: `grid-7x7`, `grid-7x2`, `ring-6`, `ring-25-filled`. Pick one at `wavegrid projects create`, or change it later:
The physical arrangement is a **layout** stored in the project — never code. Built-in presets: `grid-7x7`, `grid-7x2`, `ring-6`, `ring-25-filled`, `ring-25-hollow`, `disc-25`. Pick one at `wavegrid projects create`, or change it later:

```sh
wavegrid projects config set layout grid-7x7 # or ring-6, ring-25-filled, …
wavegrid projects config set layout grid-7x7 # a built-in preset
wavegrid projects config set layout grid:9x4 # cols × rows
wavegrid projects config set layout ring:6 # one ring
wavegrid projects config set layout annulus:25@0.5 # rings with a hole in the middle
wavegrid projects config set layout rings:12,8,4,1 # explicit rings, outermost first
```

Round rigs are concentric rings: one ring is a ring, a ring plus smaller ones inside it is a ring with a hollow centre, and rings all the way in to a centre fixture is a symmetric disc. `annulus` picks the rings for you from a cannon count and the size of the hole (`0` = solid disc).

The server resolves the layout once and broadcasts it; the UI and receiver render from it — no per-process `NUM_CANNONS`/`GRID_COLUMNS` to keep in sync. The project *name* is just a label — the preset controls the shape.

## Sharding
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,13 @@ via walk-up search (`wavegrid.json`, `.wavegridrc`, `package.json` keys,
}
```

Built-in presets: `grid-7x7`, `grid-7x2`, `ring-6`, `ring-25-filled`.
Built-in presets: `grid-7x7`, `grid-7x2`, `ring-6`, `ring-25-filled`, `ring-25-hollow`, `disc-25`.

`layout` also takes shorthand for a custom shape, so a project can map its own rig without editing JSON:

```sh
wavegrid projects config set layout grid:9x4 # cols × rows
wavegrid projects config set layout ring:6 # one ring
wavegrid projects config set layout annulus:25@0.5 # concentric rings, hole in the middle (0 = solid disc)
wavegrid projects config set layout rings:12,8,4,1 # explicit rings, outermost first
```
14 changes: 12 additions & 2 deletions packages/cli/__tests__/config-set.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,22 @@ describe('runConfigSet', () => {
expect(store.getProjectConfig('p')?.server).toEqual({ host: '10.0.0.1', port: 3000 });
});

it('rejects an unknown preset without writing', async () => {
it('accepts shorthand for a custom shape', async () => {
isolate();
const store = getStore();
store.createProject('p', { layout: { preset: 'grid-7x7' } });

await runConfigSet('layout', 'annulus:25@0.4', {});

expect(store.getProjectConfig('p')?.layout).toEqual({ kind: 'annulus', count: 25, innerRadius: 0.4 });
});

it('rejects an unknown layout without writing', async () => {
isolate();
const store = getStore();
store.createProject('p', { layout: { preset: 'ring-6' } });

await expect(runConfigSet('layout', 'nope', {})).rejects.toThrow(/Unknown preset/);
await expect(runConfigSet('layout', 'nope', {})).rejects.toThrow(/Unknown layout/);
expect(store.getProjectConfig('p')?.layout).toEqual({ preset: 'ring-6' });
});

Expand Down
23 changes: 14 additions & 9 deletions packages/cli/src/commands/config-set.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { resolveLayout, type WavegridConfig } from '@wavegrid/layout';
import { LAYOUT_SPEC_FORMS, parseLayoutSpec, resolveLayout, type WavegridConfig } from '@wavegrid/layout';
import type { Inquirerer, Question } from 'inquirerer';
import c from 'yanse';

Expand All @@ -8,12 +8,11 @@ import { type Flags, getStore, resolveProjectName } from '../project';
/** Settable config keys and how each maps into the stored project config. */
const SETTERS: Record<string, (config: Partial<WavegridConfig>, value: string) => void> = {
layout: (config, value) => {
if (!knownPresets().includes(value)) {
throw new Error(`Unknown preset "${value}". Known: ${knownPresets().join(', ')}.`);
}
// Validate the preset actually resolves before persisting.
resolveLayout({ preset: value });
config.layout = { preset: value };
// A preset id, or shorthand for a custom shape ("annulus:25@0.4").
const spec = parseLayoutSpec(value);
// Validate it actually resolves before persisting.
resolveLayout(spec);
config.layout = spec;
},
mode: (config, value) => {
if (value !== 'auto' && value !== 'simple' && value !== 'distributed') {
Expand Down Expand Up @@ -41,7 +40,7 @@ SETTERS.preset = SETTERS.layout;

/** Canonical, user-facing keys (aliases like `preset` are accepted but hidden). */
const KEY_CHOICES = [
{ value: 'layout', description: 'Layout preset (grid/ring/filled ring)' },
{ value: 'layout', description: 'Layout: a preset id or shorthand (grid/ring/annulus/rings)' },
{ value: 'mode', description: 'Run mode: auto | simple | distributed' },
{ value: 'port', description: 'Server port' },
{ value: 'host', description: 'Server host/bind address' },
Expand All @@ -68,7 +67,13 @@ function intOrThrow(key: string, value: string): number {
async function promptValue(prompter: Inquirerer, key: string): Promise<string> {
let question: Question;
if (key === 'layout' || key === 'preset') {
question = { type: 'autocomplete', name: 'value', message: 'Layout preset', options: knownPresets(), required: true };
question = {
type: 'autocomplete',
name: 'value',
message: `Layout (preset, or ${LAYOUT_SPEC_FORMS.slice(1).join(' | ')})`,
options: knownPresets(),
required: true
};
} else if (key === 'mode') {
question = { type: 'list', name: 'value', message: 'Run mode', options: ['auto', 'simple', 'distributed'], required: true };
} else if (key === 'port' || key === 'ui-port') {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { loadWavegridConfig } from '@wavegrid/layout';
import {
type Check,
checkEnvHijack,
collectDiagnostics,
type Diagnostics,
overallStatus
} from '@wavegrid/doctor';
import { loadWavegridConfig } from '@wavegrid/layout';
import { formatRanges, type SystemStatus } from '@wavegrid/server';
import c from 'yanse';

Expand Down
19 changes: 17 additions & 2 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export async function runInit(argv: RawArgv, prompter: Inquirerer): Promise<stri
type: 'list',
name: 'shape',
message: 'Layout shape',
options: ['preset', 'grid', 'ring', 'filledRing'],
options: ['preset', 'grid', 'ring', 'annulus', 'rings', 'filledRing'],
default: 'preset'
},
{
Expand Down Expand Up @@ -66,7 +66,22 @@ export async function runInit(argv: RawArgv, prompter: Inquirerer): Promise<stri
name: 'count',
message: 'Number of cannons',
default: 6,
when: (a: Partial<FullInitAnswers>) => a.shape === 'ring' || a.shape === 'filledRing'
when: (a: Partial<FullInitAnswers>) =>
a.shape === 'ring' || a.shape === 'filledRing' || a.shape === 'annulus'
},
{
type: 'number',
name: 'innerRadius',
message: 'Hole in the middle, 0–1 (0 = solid disc)',
default: 0.5,
when: (a: Partial<FullInitAnswers>) => a.shape === 'annulus'
},
{
type: 'text',
name: 'ringCounts',
message: 'Cannons per ring, outermost first (e.g. 12,8,4,1)',
default: '12,8,4,1',
when: (a: Partial<FullInitAnswers>) => a.shape === 'rings'
},
{
type: 'list',
Expand Down
16 changes: 14 additions & 2 deletions packages/cli/src/config-file.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
import { getPresetNames, type LayoutSpec, resolveLayout, type WavegridConfig } from '@wavegrid/layout';
import { getPresetNames, type LayoutSpec, parseLayoutSpec, resolveLayout, type WavegridConfig } from '@wavegrid/layout';
import { existsSync, readFileSync } from 'fs';
import { dirname, join, resolve } from 'path';

// confstash discovers `wavegrid.json` (and `.wavegridrc*`) via walk-up search.
export const CONFIG_FILENAME = 'wavegrid.json';

export type ShapeKind = 'preset' | 'grid' | 'ring' | 'filledRing';
export type ShapeKind = 'preset' | 'grid' | 'ring' | 'filledRing' | 'annulus' | 'rings';

export interface InitAnswers {
shape: ShapeKind;
preset?: string;
cols?: number;
rows?: number;
count?: number;
/** annulus: size of the hole in the middle, 0..1. */
innerRadius?: number;
/** rings: fixture counts outermost-first, e.g. "12,8,4,1". */
ringCounts?: string;
id?: string;
name?: string;
mode: 'auto' | 'simple' | 'distributed';
Expand Down Expand Up @@ -41,6 +45,14 @@ export function buildLayoutSpec(a: InitAnswers): LayoutSpec {
case 'filledRing':
if (a.count == null) throw new Error('filledRing shape requires count');
return { kind: 'filledRing', count: a.count, id: a.id, name: a.name };
case 'annulus': {
if (a.count == null) throw new Error('annulus shape requires count');
const inner = a.innerRadius ?? 0.5;
return { ...parseLayoutSpec(`annulus:${a.count}@${inner}`), id: a.id, name: a.name };
}
case 'rings':
if (!a.ringCounts) throw new Error('rings shape requires ringCounts');
return { ...parseLayoutSpec(`rings:${a.ringCounts}`), id: a.id, name: a.name };
default:
throw new Error(`unknown shape "${String(a.shape)}"`);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/desktop/__tests__/light-map.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ describe('buildLightMapView', () => {
expect(view.rows[0].corrected).toBe(true);
expect(view.rows[2].corrected).toBe(false);
expect(view.identity).toBe(false);
// rings only ever get identity + reverse.
expect(view.strategies.map((s) => s.id)).toEqual(['identity', 'reverse']);
// a ring gets the order strategies, never the grid ones.
expect(view.strategies.map((s) => s.id)).toEqual(['identity', 'reverse', 'ringCounterClockwise']);
// no OSC target configured → console.
expect(view.rows[0].oscTarget).toMatch(/console/);
});
Expand Down
43 changes: 43 additions & 0 deletions packages/desktop/__tests__/project-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { applyEditable, buildLayoutSpec, toEditable } from '@/main/project-config';

describe('buildLayoutSpec', () => {
it('builds the round shapes the wizard offers', () => {
expect(buildLayoutSpec({ kind: 'annulus', count: 25, innerRadius: 0.4 })).toEqual({
kind: 'annulus',
count: 25,
innerRadius: 0.4
});
const rings = buildLayoutSpec({ kind: 'rings', ringCounts: '12,8,4,1' });
expect(rings.kind).toBe('rings');
expect(rings.rings?.map((r) => r.count)).toEqual([12, 8, 4, 1]);
});

it('rejects an incomplete or unparseable choice with a user-facing message', () => {
expect(() => buildLayoutSpec({ kind: 'annulus' })).toThrow(/cannon count/);
expect(() => buildLayoutSpec({ kind: 'rings', ringCounts: ' ' })).toThrow(/cannons per ring/i);
expect(() => buildLayoutSpec({ kind: 'rings', ringCounts: '12,nope' })).toThrow(/whole number/);
expect(() => buildLayoutSpec({ kind: 'annulus', count: 25, innerRadius: 1 })).toThrow(/innerRadius/);
});
});

describe('editable round-trip', () => {
it('keeps an annulus intact through the editor', () => {
const stored = applyEditable(null, {
...toEditable({ layout: { kind: 'annulus', count: 25, innerRadius: 0.5 } }),
layout: { kind: 'annulus', count: 25, innerRadius: 0.5 }
});
const editable = toEditable(stored);
expect(editable.layout).toEqual({ kind: 'annulus', count: 25, innerRadius: 0.5 });
expect(editable.cannonCount).toBe(25);
});

it('shows a rings layout back as its shorthand, outermost first', () => {
const stored = applyEditable(null, {
...toEditable(null),
layout: { kind: 'rings', ringCounts: '12,8,4,1' }
});
const editable = toEditable(stored);
expect(editable.layout).toEqual({ kind: 'rings', ringCounts: '12,8,4,1' });
expect(editable.cannonCount).toBe(25);
});
});
25 changes: 24 additions & 1 deletion packages/desktop/src/main/project-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
DEFAULT_CONFIG,
getPresetNames,
type LayoutSpec,
parseLayoutSpec,
resolveLayout,
type WavegridConfig
} from '@wavegrid/layout';
Expand Down Expand Up @@ -32,6 +33,14 @@ export function buildLayoutSpec(choice: LayoutChoice): LayoutSpec {
throw new Error(`A ${choice.kind} layout needs a cannon count.`);
}
spec = { kind: choice.kind, count: choice.count };
} else if (choice.kind === 'annulus') {
if (choice.count == null) throw new Error('An annulus layout needs a cannon count.');
spec = { kind: 'annulus', count: choice.count, innerRadius: choice.innerRadius ?? 0.5 };
} else if (choice.kind === 'rings') {
if (!choice.ringCounts?.trim()) {
throw new Error('A rings layout needs cannons per ring, e.g. 12,8,4,1.');
}
spec = parseLayoutSpec(`rings:${choice.ringCounts.trim()}`);
} else {
throw new Error('Pick a preset or a custom shape for the layout.');
}
Expand All @@ -43,7 +52,21 @@ export function buildLayoutSpec(choice: LayoutChoice): LayoutSpec {
function specToChoice(spec: LayoutSpec | undefined): LayoutChoice {
if (!spec) return { preset: DEFAULT_CONFIG.layout.preset };
if (spec.preset) return { preset: spec.preset };
return { kind: spec.kind, cols: spec.cols, rows: spec.rows, count: spec.count };
if (spec.kind === 'rings') {
// Round-trip the ring list back into the shorthand the editor binds to.
const counts = [...(spec.rings ?? [])]
.sort((a, b) => b.radius - a.radius)
.map(r => r.count)
.join(',');
return { kind: 'rings', ringCounts: counts };
}
return {
kind: spec.kind,
cols: spec.cols,
rows: spec.rows,
count: spec.count,
innerRadius: spec.innerRadius
};
}

/** Build the ProjectConfig persisted for a brand-new project. Mirrors the CLI's
Expand Down
Loading
Loading