Skip to content

Commit 936a5e5

Browse files
authored
Merge pull request #91 from constructive-io/feat/rings-annulus-layouts
feat(layout): concentric ring layouts — hollow centres, symmetric discs, per-project shorthand
2 parents c8a62e8 + 8c97330 commit 936a5e5

24 files changed

Lines changed: 773 additions & 46 deletions

README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
**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.
1616

17-
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.
17+
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.
1818

1919
## Running a Show (operators)
2020

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

105105
## Layouts
106106

107-
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:
107+
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:
108108

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

117+
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).
118+
113119
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.
114120

115121
## Sharding

packages/cli/README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,4 +130,13 @@ via walk-up search (`wavegrid.json`, `.wavegridrc`, `package.json` keys,
130130
}
131131
```
132132

133-
Built-in presets: `grid-7x7`, `grid-7x2`, `ring-6`, `ring-25-filled`.
133+
Built-in presets: `grid-7x7`, `grid-7x2`, `ring-6`, `ring-25-filled`, `ring-25-hollow`, `disc-25`.
134+
135+
`layout` also takes shorthand for a custom shape, so a project can map its own rig without editing JSON:
136+
137+
```sh
138+
wavegrid projects config set layout grid:9x4 # cols × rows
139+
wavegrid projects config set layout ring:6 # one ring
140+
wavegrid projects config set layout annulus:25@0.5 # concentric rings, hole in the middle (0 = solid disc)
141+
wavegrid projects config set layout rings:12,8,4,1 # explicit rings, outermost first
142+
```

packages/cli/__tests__/config-set.test.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,22 @@ describe('runConfigSet', () => {
5151
expect(store.getProjectConfig('p')?.server).toEqual({ host: '10.0.0.1', port: 3000 });
5252
});
5353

54-
it('rejects an unknown preset without writing', async () => {
54+
it('accepts shorthand for a custom shape', async () => {
55+
isolate();
56+
const store = getStore();
57+
store.createProject('p', { layout: { preset: 'grid-7x7' } });
58+
59+
await runConfigSet('layout', 'annulus:25@0.4', {});
60+
61+
expect(store.getProjectConfig('p')?.layout).toEqual({ kind: 'annulus', count: 25, innerRadius: 0.4 });
62+
});
63+
64+
it('rejects an unknown layout without writing', async () => {
5565
isolate();
5666
const store = getStore();
5767
store.createProject('p', { layout: { preset: 'ring-6' } });
5868

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

packages/cli/src/commands/config-set.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { resolveLayout, type WavegridConfig } from '@wavegrid/layout';
1+
import { LAYOUT_SPEC_FORMS, parseLayoutSpec, resolveLayout, type WavegridConfig } from '@wavegrid/layout';
22
import type { Inquirerer, Question } from 'inquirerer';
33
import c from 'yanse';
44

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

4241
/** Canonical, user-facing keys (aliases like `preset` are accepted but hidden). */
4342
const KEY_CHOICES = [
44-
{ value: 'layout', description: 'Layout preset (grid/ring/filled ring)' },
43+
{ value: 'layout', description: 'Layout: a preset id or shorthand (grid/ring/annulus/rings)' },
4544
{ value: 'mode', description: 'Run mode: auto | simple | distributed' },
4645
{ value: 'port', description: 'Server port' },
4746
{ value: 'host', description: 'Server host/bind address' },
@@ -68,7 +67,13 @@ function intOrThrow(key: string, value: string): number {
6867
async function promptValue(prompter: Inquirerer, key: string): Promise<string> {
6968
let question: Question;
7069
if (key === 'layout' || key === 'preset') {
71-
question = { type: 'autocomplete', name: 'value', message: 'Layout preset', options: knownPresets(), required: true };
70+
question = {
71+
type: 'autocomplete',
72+
name: 'value',
73+
message: `Layout (preset, or ${LAYOUT_SPEC_FORMS.slice(1).join(' | ')})`,
74+
options: knownPresets(),
75+
required: true
76+
};
7277
} else if (key === 'mode') {
7378
question = { type: 'list', name: 'value', message: 'Run mode', options: ['auto', 'simple', 'distributed'], required: true };
7479
} else if (key === 'port' || key === 'ui-port') {

packages/cli/src/commands/doctor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import { loadWavegridConfig } from '@wavegrid/layout';
21
import {
32
type Check,
43
checkEnvHijack,
54
collectDiagnostics,
65
type Diagnostics,
76
overallStatus
87
} from '@wavegrid/doctor';
8+
import { loadWavegridConfig } from '@wavegrid/layout';
99
import { formatRanges, type SystemStatus } from '@wavegrid/server';
1010
import c from 'yanse';
1111

packages/cli/src/commands/init.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export async function runInit(argv: RawArgv, prompter: Inquirerer): Promise<stri
3636
type: 'list',
3737
name: 'shape',
3838
message: 'Layout shape',
39-
options: ['preset', 'grid', 'ring', 'filledRing'],
39+
options: ['preset', 'grid', 'ring', 'annulus', 'rings', 'filledRing'],
4040
default: 'preset'
4141
},
4242
{
@@ -66,7 +66,22 @@ export async function runInit(argv: RawArgv, prompter: Inquirerer): Promise<stri
6666
name: 'count',
6767
message: 'Number of cannons',
6868
default: 6,
69-
when: (a: Partial<FullInitAnswers>) => a.shape === 'ring' || a.shape === 'filledRing'
69+
when: (a: Partial<FullInitAnswers>) =>
70+
a.shape === 'ring' || a.shape === 'filledRing' || a.shape === 'annulus'
71+
},
72+
{
73+
type: 'number',
74+
name: 'innerRadius',
75+
message: 'Hole in the middle, 0–1 (0 = solid disc)',
76+
default: 0.5,
77+
when: (a: Partial<FullInitAnswers>) => a.shape === 'annulus'
78+
},
79+
{
80+
type: 'text',
81+
name: 'ringCounts',
82+
message: 'Cannons per ring, outermost first (e.g. 12,8,4,1)',
83+
default: '12,8,4,1',
84+
when: (a: Partial<FullInitAnswers>) => a.shape === 'rings'
7085
},
7186
{
7287
type: 'list',

packages/cli/src/config-file.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
1-
import { getPresetNames, type LayoutSpec, resolveLayout, type WavegridConfig } from '@wavegrid/layout';
1+
import { getPresetNames, type LayoutSpec, parseLayoutSpec, resolveLayout, type WavegridConfig } from '@wavegrid/layout';
22
import { existsSync, readFileSync } from 'fs';
33
import { dirname, join, resolve } from 'path';
44

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

8-
export type ShapeKind = 'preset' | 'grid' | 'ring' | 'filledRing';
8+
export type ShapeKind = 'preset' | 'grid' | 'ring' | 'filledRing' | 'annulus' | 'rings';
99

1010
export interface InitAnswers {
1111
shape: ShapeKind;
1212
preset?: string;
1313
cols?: number;
1414
rows?: number;
1515
count?: number;
16+
/** annulus: size of the hole in the middle, 0..1. */
17+
innerRadius?: number;
18+
/** rings: fixture counts outermost-first, e.g. "12,8,4,1". */
19+
ringCounts?: string;
1620
id?: string;
1721
name?: string;
1822
mode: 'auto' | 'simple' | 'distributed';
@@ -41,6 +45,14 @@ export function buildLayoutSpec(a: InitAnswers): LayoutSpec {
4145
case 'filledRing':
4246
if (a.count == null) throw new Error('filledRing shape requires count');
4347
return { kind: 'filledRing', count: a.count, id: a.id, name: a.name };
48+
case 'annulus': {
49+
if (a.count == null) throw new Error('annulus shape requires count');
50+
const inner = a.innerRadius ?? 0.5;
51+
return { ...parseLayoutSpec(`annulus:${a.count}@${inner}`), id: a.id, name: a.name };
52+
}
53+
case 'rings':
54+
if (!a.ringCounts) throw new Error('rings shape requires ringCounts');
55+
return { ...parseLayoutSpec(`rings:${a.ringCounts}`), id: a.id, name: a.name };
4456
default:
4557
throw new Error(`unknown shape "${String(a.shape)}"`);
4658
}

packages/desktop/__tests__/light-map.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ describe('buildLightMapView', () => {
5656
expect(view.rows[0].corrected).toBe(true);
5757
expect(view.rows[2].corrected).toBe(false);
5858
expect(view.identity).toBe(false);
59-
// rings only ever get identity + reverse.
60-
expect(view.strategies.map((s) => s.id)).toEqual(['identity', 'reverse']);
59+
// a ring gets the order strategies, never the grid ones.
60+
expect(view.strategies.map((s) => s.id)).toEqual(['identity', 'reverse', 'ringCounterClockwise']);
6161
// no OSC target configured → console.
6262
expect(view.rows[0].oscTarget).toMatch(/console/);
6363
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { applyEditable, buildLayoutSpec, toEditable } from '@/main/project-config';
2+
3+
describe('buildLayoutSpec', () => {
4+
it('builds the round shapes the wizard offers', () => {
5+
expect(buildLayoutSpec({ kind: 'annulus', count: 25, innerRadius: 0.4 })).toEqual({
6+
kind: 'annulus',
7+
count: 25,
8+
innerRadius: 0.4
9+
});
10+
const rings = buildLayoutSpec({ kind: 'rings', ringCounts: '12,8,4,1' });
11+
expect(rings.kind).toBe('rings');
12+
expect(rings.rings?.map((r) => r.count)).toEqual([12, 8, 4, 1]);
13+
});
14+
15+
it('rejects an incomplete or unparseable choice with a user-facing message', () => {
16+
expect(() => buildLayoutSpec({ kind: 'annulus' })).toThrow(/cannon count/);
17+
expect(() => buildLayoutSpec({ kind: 'rings', ringCounts: ' ' })).toThrow(/cannons per ring/i);
18+
expect(() => buildLayoutSpec({ kind: 'rings', ringCounts: '12,nope' })).toThrow(/whole number/);
19+
expect(() => buildLayoutSpec({ kind: 'annulus', count: 25, innerRadius: 1 })).toThrow(/innerRadius/);
20+
});
21+
});
22+
23+
describe('editable round-trip', () => {
24+
it('keeps an annulus intact through the editor', () => {
25+
const stored = applyEditable(null, {
26+
...toEditable({ layout: { kind: 'annulus', count: 25, innerRadius: 0.5 } }),
27+
layout: { kind: 'annulus', count: 25, innerRadius: 0.5 }
28+
});
29+
const editable = toEditable(stored);
30+
expect(editable.layout).toEqual({ kind: 'annulus', count: 25, innerRadius: 0.5 });
31+
expect(editable.cannonCount).toBe(25);
32+
});
33+
34+
it('shows a rings layout back as its shorthand, outermost first', () => {
35+
const stored = applyEditable(null, {
36+
...toEditable(null),
37+
layout: { kind: 'rings', ringCounts: '12,8,4,1' }
38+
});
39+
const editable = toEditable(stored);
40+
expect(editable.layout).toEqual({ kind: 'rings', ringCounts: '12,8,4,1' });
41+
expect(editable.cannonCount).toBe(25);
42+
});
43+
});

packages/desktop/src/main/project-config.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
DEFAULT_CONFIG,
66
getPresetNames,
77
type LayoutSpec,
8+
parseLayoutSpec,
89
resolveLayout,
910
type WavegridConfig
1011
} from '@wavegrid/layout';
@@ -32,6 +33,14 @@ export function buildLayoutSpec(choice: LayoutChoice): LayoutSpec {
3233
throw new Error(`A ${choice.kind} layout needs a cannon count.`);
3334
}
3435
spec = { kind: choice.kind, count: choice.count };
36+
} else if (choice.kind === 'annulus') {
37+
if (choice.count == null) throw new Error('An annulus layout needs a cannon count.');
38+
spec = { kind: 'annulus', count: choice.count, innerRadius: choice.innerRadius ?? 0.5 };
39+
} else if (choice.kind === 'rings') {
40+
if (!choice.ringCounts?.trim()) {
41+
throw new Error('A rings layout needs cannons per ring, e.g. 12,8,4,1.');
42+
}
43+
spec = parseLayoutSpec(`rings:${choice.ringCounts.trim()}`);
3544
} else {
3645
throw new Error('Pick a preset or a custom shape for the layout.');
3746
}
@@ -43,7 +52,21 @@ export function buildLayoutSpec(choice: LayoutChoice): LayoutSpec {
4352
function specToChoice(spec: LayoutSpec | undefined): LayoutChoice {
4453
if (!spec) return { preset: DEFAULT_CONFIG.layout.preset };
4554
if (spec.preset) return { preset: spec.preset };
46-
return { kind: spec.kind, cols: spec.cols, rows: spec.rows, count: spec.count };
55+
if (spec.kind === 'rings') {
56+
// Round-trip the ring list back into the shorthand the editor binds to.
57+
const counts = [...(spec.rings ?? [])]
58+
.sort((a, b) => b.radius - a.radius)
59+
.map(r => r.count)
60+
.join(',');
61+
return { kind: 'rings', ringCounts: counts };
62+
}
63+
return {
64+
kind: spec.kind,
65+
cols: spec.cols,
66+
rows: spec.rows,
67+
count: spec.count,
68+
innerRadius: spec.innerRadius
69+
};
4770
}
4871

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

0 commit comments

Comments
 (0)