From 8c973305d8341de2de7de8b1975d9e2b8dc75444 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 9 Aug 2026 06:54:28 +0000 Subject: [PATCH] =?UTF-8?q?feat(layout):=20concentric=20ring=20layouts=20?= =?UTF-8?q?=E2=80=94=20hollow=20centres,=20symmetric=20discs,=20per-projec?= =?UTF-8?q?t=20shorthand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 +- packages/cli/README.md | 11 +- packages/cli/__tests__/config-set.test.ts | 14 +- packages/cli/src/commands/config-set.ts | 23 ++- packages/cli/src/commands/doctor.ts | 2 +- packages/cli/src/commands/init.ts | 19 ++- packages/cli/src/config-file.ts | 16 +- packages/desktop/__tests__/light-map.test.ts | 4 +- .../desktop/__tests__/project-config.test.ts | 43 +++++ packages/desktop/src/main/project-config.ts | 25 ++- .../src/renderer/routes/config-route.tsx | 58 ++++++- .../renderer/routes/create-project-dialog.tsx | 48 +++++- packages/desktop/src/types/ipc.ts | 11 +- packages/layout/README.md | 11 +- packages/layout/__tests__/generators.test.ts | 87 +++++++++- packages/layout/__tests__/layout-spec.test.ts | 52 ++++++ packages/layout/__tests__/light-map.test.ts | 28 +++- packages/layout/src/client.ts | 11 +- packages/layout/src/generators.ts | 157 +++++++++++++++++- packages/layout/src/index.ts | 11 +- packages/layout/src/layout-spec.ts | 95 +++++++++++ packages/layout/src/light-map.ts | 31 ++++ packages/layout/src/presets.ts | 20 ++- packages/layout/src/types.ts | 28 +++- 24 files changed, 773 insertions(+), 46 deletions(-) create mode 100644 packages/desktop/__tests__/project-config.test.ts create mode 100644 packages/layout/__tests__/layout-spec.test.ts create mode 100644 packages/layout/src/layout-spec.ts diff --git a/README.md b/README.md index c2a128f..861b296 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 | @@ -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 diff --git a/packages/cli/README.md b/packages/cli/README.md index d303d7b..e747c0a 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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 +``` diff --git a/packages/cli/__tests__/config-set.test.ts b/packages/cli/__tests__/config-set.test.ts index 55bbccf..9d2b991 100644 --- a/packages/cli/__tests__/config-set.test.ts +++ b/packages/cli/__tests__/config-set.test.ts @@ -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' }); }); diff --git a/packages/cli/src/commands/config-set.ts b/packages/cli/src/commands/config-set.ts index 61e9402..ea6f143 100644 --- a/packages/cli/src/commands/config-set.ts +++ b/packages/cli/src/commands/config-set.ts @@ -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'; @@ -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, 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') { @@ -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' }, @@ -68,7 +67,13 @@ function intOrThrow(key: string, value: string): number { async function promptValue(prompter: Inquirerer, key: string): Promise { 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') { diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 6206ff1..8d4bbac 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -1,4 +1,3 @@ -import { loadWavegridConfig } from '@wavegrid/layout'; import { type Check, checkEnvHijack, @@ -6,6 +5,7 @@ import { type Diagnostics, overallStatus } from '@wavegrid/doctor'; +import { loadWavegridConfig } from '@wavegrid/layout'; import { formatRanges, type SystemStatus } from '@wavegrid/server'; import c from 'yanse'; diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 0781af6..8e853d5 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -36,7 +36,7 @@ export async function runInit(argv: RawArgv, prompter: Inquirerer): Promise) => a.shape === 'ring' || a.shape === 'filledRing' + when: (a: Partial) => + 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) => 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) => a.shape === 'rings' }, { type: 'list', diff --git a/packages/cli/src/config-file.ts b/packages/cli/src/config-file.ts index a9c2d5b..d26025d 100644 --- a/packages/cli/src/config-file.ts +++ b/packages/cli/src/config-file.ts @@ -1,11 +1,11 @@ -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; @@ -13,6 +13,10 @@ export interface InitAnswers { 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'; @@ -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)}"`); } diff --git a/packages/desktop/__tests__/light-map.test.ts b/packages/desktop/__tests__/light-map.test.ts index e292dde..d6da43d 100644 --- a/packages/desktop/__tests__/light-map.test.ts +++ b/packages/desktop/__tests__/light-map.test.ts @@ -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/); }); diff --git a/packages/desktop/__tests__/project-config.test.ts b/packages/desktop/__tests__/project-config.test.ts new file mode 100644 index 0000000..12a0d11 --- /dev/null +++ b/packages/desktop/__tests__/project-config.test.ts @@ -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); + }); +}); diff --git a/packages/desktop/src/main/project-config.ts b/packages/desktop/src/main/project-config.ts index ca8c5af..301c811 100644 --- a/packages/desktop/src/main/project-config.ts +++ b/packages/desktop/src/main/project-config.ts @@ -5,6 +5,7 @@ import { DEFAULT_CONFIG, getPresetNames, type LayoutSpec, + parseLayoutSpec, resolveLayout, type WavegridConfig } from '@wavegrid/layout'; @@ -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.'); } @@ -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 diff --git a/packages/desktop/src/renderer/routes/config-route.tsx b/packages/desktop/src/renderer/routes/config-route.tsx index 6bc3060..517e9d2 100644 --- a/packages/desktop/src/renderer/routes/config-route.tsx +++ b/packages/desktop/src/renderer/routes/config-route.tsx @@ -15,7 +15,13 @@ import { Label } from '@/components/ui/label'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import type { EditableConfig, LayoutChoice } from '@/types/ipc'; -type Shape = 'preset' | 'grid' | 'ring' | 'filledRing'; +type Shape = 'preset' | 'grid' | 'ring' | 'annulus' | 'rings' | 'filledRing'; + +/** Cannons per ring, outermost first — "12,8,4,1". */ +function ringCountsValid(text: string | undefined): boolean { + const parts = (text ?? '').split(',').map((p) => p.trim()); + return parts.length > 0 && parts.every((p) => /^\d+$/.test(p) && Number(p) >= 1); +} function shapeOf(layout: LayoutChoice): Shape { if (layout.preset) return 'preset'; @@ -130,7 +136,13 @@ export function ConfigRoute({ project, config, loading, onSave, busy }: ConfigRo ? !!draft.layout.preset : shape === 'grid' ? Number(draft.layout.cols) >= 1 && Number(draft.layout.rows) >= 1 - : Number(draft.layout.count) >= 1; + : shape === 'rings' + ? ringCountsValid(draft.layout.ringCounts) + : shape === 'annulus' + ? Number(draft.layout.count) >= 1 && + Number(draft.layout.innerRadius) >= 0 && + Number(draft.layout.innerRadius) < 1 + : Number(draft.layout.count) >= 1; const valid = layoutValid && Number.isInteger(numeric(String(draft.serverPort))) && @@ -193,12 +205,22 @@ export function ConfigRoute({ project, config, loading, onSave, busy }: ConfigRo if (next === 'preset') setLayout({ preset: draft.layout.preset ?? '' }); else if (next === 'grid') setLayout({ kind: 'grid', cols: draft.layout.cols ?? 7, rows: draft.layout.rows ?? 7 }); + else if (next === 'rings') + setLayout({ kind: 'rings', ringCounts: draft.layout.ringCounts ?? '12,8,4,1' }); + else if (next === 'annulus') + setLayout({ + kind: 'annulus', + count: draft.layout.count ?? 25, + innerRadius: draft.layout.innerRadius ?? 0.5 + }); else setLayout({ kind: next, count: draft.layout.count ?? 6 }); }} options={[ { value: 'preset', label: 'Preset' }, { value: 'grid', label: 'Grid' }, { value: 'ring', label: 'Ring' }, + { value: 'annulus', label: 'Ring w/ hole' }, + { value: 'rings', label: 'Concentric rings' }, { value: 'filledRing', label: 'Filled ring' } ]} /> @@ -241,6 +263,38 @@ export function ConfigRoute({ project, config, loading, onSave, busy }: ConfigRo onChange={(v) => setLayout({ kind: shape, count: Number(v) })} /> )} + {shape === 'annulus' && ( +
+ + setLayout({ kind: 'annulus', count: Number(v), innerRadius: draft.layout.innerRadius ?? 0.5 }) + } + /> + + setLayout({ kind: 'annulus', count: draft.layout.count ?? 25, innerRadius: Number(v) }) + } + /> +
+ )} + {shape === 'rings' && ( + setLayout({ kind: 'rings', ringCounts: v })} + /> + )}
diff --git a/packages/desktop/src/renderer/routes/create-project-dialog.tsx b/packages/desktop/src/renderer/routes/create-project-dialog.tsx index 118caa6..6b577a9 100644 --- a/packages/desktop/src/renderer/routes/create-project-dialog.tsx +++ b/packages/desktop/src/renderer/routes/create-project-dialog.tsx @@ -15,7 +15,13 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import type { LayoutChoice, NewProjectInput } from '@/types/ipc'; -type Shape = 'preset' | 'grid' | 'ring' | 'filledRing'; +type Shape = 'preset' | 'grid' | 'ring' | 'annulus' | 'rings' | 'filledRing'; + +/** Cannons per ring, outermost first — "12,8,4,1". */ +function ringCountsValid(text: string): boolean { + const parts = text.split(',').map((p) => p.trim()); + return parts.length > 0 && parts.every((p) => /^\d+$/.test(p) && Number(p) >= 1); +} const NAME_RE = /^[a-zA-Z0-9._-]+$/; @@ -99,6 +105,8 @@ export function CreateProjectDialog({ const [cols, setCols] = React.useState('7'); const [rows, setRows] = React.useState('7'); const [count, setCount] = React.useState('6'); + const [innerRadius, setInnerRadius] = React.useState('0.5'); + const [ringCounts, setRingCounts] = React.useState('12,8,4,1'); const [mode, setMode] = React.useState('auto'); const [serverHost, setServerHost] = React.useState('0.0.0.0'); @@ -119,6 +127,8 @@ export function CreateProjectDialog({ setCols('7'); setRows('7'); setCount('6'); + setInnerRadius('0.5'); + setRingCounts('12,8,4,1'); setMode('auto'); setServerHost('0.0.0.0'); setServerPort('3000'); @@ -144,6 +154,8 @@ export function CreateProjectDialog({ const layoutChoice = (): LayoutChoice => { if (shape === 'preset') return { preset }; if (shape === 'grid') return { kind: 'grid', cols: Number(cols), rows: Number(rows) }; + if (shape === 'rings') return { kind: 'rings', ringCounts }; + if (shape === 'annulus') return { kind: 'annulus', count: Number(count), innerRadius: Number(innerRadius) }; return { kind: shape, count: Number(count) }; }; @@ -152,7 +164,11 @@ export function CreateProjectDialog({ ? preset !== '' : shape === 'grid' ? Number(cols) >= 1 && Number(rows) >= 1 - : Number(count) >= 1; + : shape === 'rings' + ? ringCountsValid(ringCounts) + : shape === 'annulus' + ? Number(count) >= 1 && Number(innerRadius) >= 0 && Number(innerRadius) < 1 + : Number(count) >= 1; const step1Valid = !nameError && layoutValid; const portsValid = @@ -224,6 +240,8 @@ export function CreateProjectDialog({ { value: 'preset', label: 'Preset' }, { value: 'grid', label: 'Grid' }, { value: 'ring', label: 'Ring' }, + { value: 'annulus', label: 'Ring w/ hole' }, + { value: 'rings', label: 'Concentric rings' }, { value: 'filledRing', label: 'Filled ring' } ]} /> @@ -248,6 +266,32 @@ export function CreateProjectDialog({ {(shape === 'ring' || shape === 'filledRing') && ( )} + {shape === 'annulus' && ( +
+ +
+ + setInnerRadius(e.target.value)} + className='w-32' + /> +
+
+ )} + {shape === 'rings' && ( +
+ + setRingCounts(e.target.value)} + /> +
+ )} ) : ( <> diff --git a/packages/desktop/src/types/ipc.ts b/packages/desktop/src/types/ipc.ts index d8b3b56..ebe42d6 100644 --- a/packages/desktop/src/types/ipc.ts +++ b/packages/desktop/src/types/ipc.ts @@ -22,13 +22,18 @@ export interface ProjectSummary { } /** How a project's layout is chosen — a built-in preset id, or a generated - * shape (grid cols×rows, ring/filledRing count). Mirrors the CLI's LayoutSpec. */ + * shape (grid cols×rows, ring/filledRing/annulus count, explicit ring counts). + * Mirrors the CLI's LayoutSpec. */ export interface LayoutChoice { preset?: string; - kind?: 'grid' | 'ring' | 'filledRing'; + kind?: 'grid' | 'ring' | 'filledRing' | 'annulus' | 'rings'; cols?: number; rows?: number; count?: number; + /** annulus: size of the hole in the middle, 0..1. */ + innerRadius?: number; + /** rings: cannons per ring, outermost first, e.g. "12,8,4,1". */ + ringCounts?: string; } /** Input for the create-project wizard. Main turns this into a ProjectConfig, @@ -209,7 +214,7 @@ export interface LightMapEntry { export interface LightMapView { project: string; layoutName: string; - topology: 'grid' | 'ring' | 'filledRing'; + topology: 'grid' | 'ring' | 'filledRing' | 'rings'; numCannons: number; gridColumns: number; physicalLights: number[]; diff --git a/packages/layout/README.md b/packages/layout/README.md index e3eef60..0a4e852 100644 --- a/packages/layout/README.md +++ b/packages/layout/README.md @@ -15,17 +15,26 @@ server resolves it once and broadcasts it to every client. ## Shapes ```ts -import { gridLayout, ringLayout, filledRingLayout, resolveLayout } from '@wavegrid/layout'; +import { gridLayout, ringLayout, ringsLayout, annulusLayout, filledRingLayout, resolveLayout, parseLayoutSpec } from '@wavegrid/layout'; gridLayout({ cols: 7, rows: 7 }); // the OG 49-cannon grid gridLayout({ cols: 7, rows: 2 }); // 7×2 ringLayout({ count: 6 }); // 6 cannons in a circle filledRingLayout({ count: 25 }); // 25 in a filled disc (grid + circular mask) +// Concentric rings — the general round shape. Radii are relative, 0 is the centre. +ringsLayout({ rings: [{ count: 16, radius: 1 }, { count: 8, radius: 0.6, phase: 22.5 }] }); +annulusLayout({ count: 25, innerRadius: 0.5 }); // rings chosen for you, hole in the middle +annulusLayout({ count: 25, innerRadius: 0 }); // symmetric disc (12+8+4+1) + resolveLayout({ preset: 'ring-6' }); // by preset id resolveLayout({ kind: 'ring', count: 12 }); +resolveLayout(parseLayoutSpec('annulus:25@0.5')); // from user-typed shorthand ``` +Fixtures are emitted outermost ring first, each clockwise from 12 o'clock, so +shard slices and light maps stay contiguous per ring. + Each `Fixture` carries `u/v` (normalized), `x/y` (centered), `angle`, `radius`, `ring`, and grid `row/col` (when the layout has grid coordinates). Patterns and animations consume these directly, so the same effect runs on a rectangle or a diff --git a/packages/layout/__tests__/generators.test.ts b/packages/layout/__tests__/generators.test.ts index 71c44f9..0c0110f 100644 --- a/packages/layout/__tests__/generators.test.ts +++ b/packages/layout/__tests__/generators.test.ts @@ -1,4 +1,4 @@ -import { filledRingLayout, gridLayout, ringLayout } from '../src/generators'; +import { annulusLayout, filledRingLayout, gridLayout, ringLayout, ringsLayout } from '../src/generators'; describe('gridLayout', () => { it('creates a row-major grid with correct count and grid coords', () => { @@ -99,3 +99,88 @@ describe('filledRingLayout', () => { expect(layout.perimeter.length).toBeLessThanOrEqual(layout.count); }); }); + +describe('ringsLayout', () => { + it('emits rings outermost first, each clockwise from 12 o’clock', () => { + const layout = ringsLayout({ rings: [{ count: 4, radius: 0.5 }, { count: 8, radius: 1 }] }); + expect(layout.topology).toBe('rings'); + expect(layout.count).toBe(12); + expect(layout.hasGridCoords).toBe(false); + expect(layout.cols).toBe(0); + // outer ring (8) first + expect(layout.fixtures.slice(0, 8).every(f => f.radius > 0.9)).toBe(true); + expect(layout.fixtures.slice(8).every(f => f.radius < 0.6)).toBe(true); + expect(layout.fixtures[0].x).toBeCloseTo(0); + expect(layout.fixtures[0].y).toBeCloseTo(-1); + expect(layout.fixtures[1].x).toBeGreaterThan(0); + }); + + it('indexes rings from the inside out and takes the outer ring as perimeter', () => { + const layout = ringsLayout({ rings: [{ count: 1, radius: 0 }, { count: 6, radius: 1 }, { count: 3, radius: 0.5 }] }); + expect(layout.count).toBe(10); + expect(layout.perimeter).toEqual([0, 1, 2, 3, 4, 5]); + const centre = layout.fixtures[layout.count - 1]; + expect(centre.ring).toBe(0); + expect(centre.radius).toBeCloseTo(0); + expect(layout.fixtures[0].ring).toBe(2); + }); + + it('phase rotates a ring', () => { + const straight = ringsLayout({ rings: [{ count: 4, radius: 1 }] }); + const turned = ringsLayout({ rings: [{ count: 4, radius: 1, phase: 45 }] }); + expect(turned.fixtures[0].x).toBeCloseTo(Math.SQRT1_2); + expect(straight.fixtures[0].x).toBeCloseTo(0); + }); + + it('rejects malformed rings', () => { + expect(() => ringsLayout({ rings: [] })).toThrow(/at least one ring/); + expect(() => ringsLayout({ rings: [{ count: 0, radius: 1 }] })).toThrow(/count >= 1/); + expect(() => ringsLayout({ rings: [{ count: 2, radius: 0 }] })).toThrow(/centre ring/); + expect(() => ringsLayout({ rings: [{ count: 2, radius: 1 }, { count: 3, radius: 1 }] })).toThrow(/distinct radius/); + }); +}); + +describe('annulusLayout', () => { + it('spreads the count over concentric rings, leaving a hole', () => { + const layout = annulusLayout({ count: 25, innerRadius: 0.5 }); + expect(layout.topology).toBe('rings'); + expect(layout.count).toBe(25); + // nothing inside the hole + layout.fixtures.forEach(f => expect(f.radius).toBeGreaterThanOrEqual(0.5 - 1e-9)); + // more than one ring, so it is not just a plain ring + expect(new Set(layout.fixtures.map(f => f.ring)).size).toBeGreaterThan(1); + }); + + it('is symmetric: every ring is evenly spaced', () => { + const layout = annulusLayout({ count: 25, innerRadius: 0.5 }); + const byRing = new Map(); + layout.fixtures.forEach(f => byRing.set(f.ring, [...(byRing.get(f.ring) ?? []), f.angle])); + for (const angles of byRing.values()) { + if (angles.length < 3) continue; + const sorted = [...angles].sort((a, b) => a - b); + const gaps = sorted.slice(1).map((a, i) => a - sorted[i]); + const expected = (Math.PI * 2) / angles.length; + gaps.forEach(g => expect(g).toBeCloseTo(expected, 5)); + } + }); + + it('innerRadius 0 puts a single fixture at the centre (symmetric disc)', () => { + const layout = annulusLayout({ count: 25, innerRadius: 0 }); + const centres = layout.fixtures.filter(f => f.radius < 1e-9); + expect(centres).toHaveLength(1); + expect(layout.count).toBe(25); + }); + + it('hits the exact count for every size', () => { + for (let n = 1; n <= 80; n++) { + expect(annulusLayout({ count: n, innerRadius: 0.5 }).count).toBe(n); + expect(annulusLayout({ count: n, innerRadius: 0 }).count).toBe(n); + } + }); + + it('rejects a bad count or innerRadius', () => { + expect(() => annulusLayout({ count: 0 })).toThrow(/count >= 1/); + expect(() => annulusLayout({ count: 10, innerRadius: 1 })).toThrow(/innerRadius/); + expect(() => annulusLayout({ count: 10, innerRadius: -0.1 })).toThrow(/innerRadius/); + }); +}); diff --git a/packages/layout/__tests__/layout-spec.test.ts b/packages/layout/__tests__/layout-spec.test.ts new file mode 100644 index 0000000..6f1f9e5 --- /dev/null +++ b/packages/layout/__tests__/layout-spec.test.ts @@ -0,0 +1,52 @@ +import { resolveLayout } from '../src/presets'; +import { parseLayoutSpec } from '../src/layout-spec'; + +describe('parseLayoutSpec', () => { + it('takes a preset id as-is', () => { + expect(parseLayoutSpec('grid-7x7')).toEqual({ preset: 'grid-7x7' }); + expect(parseLayoutSpec(' ring-6 ')).toEqual({ preset: 'ring-6' }); + }); + + it('parses each custom shorthand', () => { + expect(parseLayoutSpec('grid:7x2')).toEqual({ kind: 'grid', cols: 7, rows: 2 }); + expect(parseLayoutSpec('ring:6')).toEqual({ kind: 'ring', count: 6 }); + expect(parseLayoutSpec('filled:25')).toEqual({ kind: 'filledRing', count: 25 }); + expect(parseLayoutSpec('annulus:25')).toEqual({ kind: 'annulus', count: 25 }); + expect(parseLayoutSpec('annulus:25@0.35')).toEqual({ kind: 'annulus', count: 25, innerRadius: 0.35 }); + }); + + it('turns ring counts into evenly spaced rings, innermost 1 at the centre', () => { + const spec = parseLayoutSpec('rings:12,8,4,1'); + expect(spec.kind).toBe('rings'); + expect(spec.rings?.map(r => r.count)).toEqual([12, 8, 4, 1]); + expect(spec.rings?.[0].radius).toBeCloseTo(1); + expect(spec.rings?.[3].radius).toBe(0); + const layout = resolveLayout(spec); + expect(layout.count).toBe(25); + expect(layout.fixtures.filter(f => f.radius < 1e-9)).toHaveLength(1); + }); + + it('keeps a lone inner ring off-centre when it holds more than one fixture', () => { + const spec = parseLayoutSpec('rings:16,9'); + expect(spec.rings?.[1].radius).toBeCloseTo(0.5); + expect(resolveLayout(spec).count).toBe(25); + }); + + it('rejects nonsense with the accepted forms', () => { + expect(() => parseLayoutSpec('')).toThrow(/Empty layout/); + expect(() => parseLayoutSpec('nope')).toThrow(/Unknown layout/); + expect(() => parseLayoutSpec('blob:3')).toThrow(/Unknown layout kind/); + expect(() => parseLayoutSpec('grid:7')).toThrow(/x/); + expect(() => parseLayoutSpec('ring:0')).toThrow(/whole number/); + expect(() => parseLayoutSpec('annulus:25@1')).toThrow(/innerRadius/); + expect(() => parseLayoutSpec('rings:12,x')).toThrow(/whole number/); + }); +}); + +describe('resolveLayout precedence', () => { + it('an explicit kind wins over a preset merged in from the defaults', () => { + const layout = resolveLayout({ preset: 'grid-7x7', kind: 'annulus', count: 25, innerRadius: 0.5 }); + expect(layout.topology).toBe('rings'); + expect(layout.count).toBe(25); + }); +}); diff --git a/packages/layout/__tests__/light-map.test.ts b/packages/layout/__tests__/light-map.test.ts index 60aa99c..ab82d7f 100644 --- a/packages/layout/__tests__/light-map.test.ts +++ b/packages/layout/__tests__/light-map.test.ts @@ -12,6 +12,7 @@ import type { Layout } from '../src/types'; const grid7x7 = (): Layout => resolveLayout({ preset: 'grid-7x7' }); const grid7x2 = (): Layout => resolveLayout({ kind: 'grid', cols: 7, rows: 2 }); const ring6 = (): Layout => resolveLayout({ preset: 'ring-6' }); +const hollow25 = (): Layout => resolveLayout({ preset: 'ring-25-hollow' }); const isPermutation = (map: number[], count: number): boolean => { if (map.length !== count) return false; @@ -87,9 +88,32 @@ describe('auto-map heuristics', () => { expect(availableStrategies(grid7x2()).some((s) => s.id === 'rotate90')).toBe(false); }); - it('rings only get identity + reverse', () => { + it('a single ring gets the order strategies, not the grid ones', () => { const ids = availableStrategies(ring6()).map((s) => s.id); - expect(ids).toEqual(['identity', 'reverse']); + expect(ids).toEqual(['identity', 'reverse', 'ringCounterClockwise']); + }); + + it('concentric rings also offer innermost-first', () => { + const ids = availableStrategies(hollow25()).map((s) => s.id); + expect(ids).toEqual(['identity', 'reverse', 'ringCounterClockwise', 'ringsInnerFirst']); + }); + + it('ringCounterClockwise reverses each ring but keeps its 12 o\u2019clock start', () => { + const map = autoMap(ring6(), 'ringCounterClockwise'); + expect(isPermutation(map, 6)).toBe(true); + expect(map[0]).toBe(0); + expect(map[1]).toBe(5); + expect(map[5]).toBe(1); + }); + + it('ringsInnerFirst drives the centre from physical output 0', () => { + const layout = hollow25(); + const map = autoMap(layout, 'ringsInnerFirst'); + expect(isPermutation(map, layout.count)).toBe(true); + const innermost = layout.fixtures.filter((f) => f.ring === 0).map((f) => f.index); + expect(innermost.map((i) => map[i])).toEqual(innermost.map((_, slot) => slot)); + // the outermost ring lands at the end + expect(map[0]).toBe(layout.count - layout.perimeter.length); }); it('unknown strategy id falls back to identity', () => { diff --git a/packages/layout/src/client.ts b/packages/layout/src/client.ts index 2132a73..8b07111 100644 --- a/packages/layout/src/client.ts +++ b/packages/layout/src/client.ts @@ -8,9 +8,11 @@ export type { Fb4Config, Fixture, Layout, + LayoutKind, LayoutSpec, OscConfig, ReceiverConfig, + RingSpec, RunMode, ServerConfig, ShardConfig, @@ -21,13 +23,20 @@ export type { // Generators export { + annulusLayout, + type AnnulusParams, filledRingLayout, type FilledRingParams, gridLayout, type GridParams, ringLayout, - type RingParams + type RingParams, + ringsLayout, + type RingsParams } from './generators'; // Presets + spec resolution export { getPresetNames, presets, resolveLayout } from './presets'; + +// Human-writable layout shorthand ("annulus:25@0.4", "rings:12,8,4,1", …) +export { LAYOUT_SPEC_FORMS, parseLayoutSpec } from './layout-spec'; diff --git a/packages/layout/src/generators.ts b/packages/layout/src/generators.ts index 0fb3b84..24ed51b 100644 --- a/packages/layout/src/generators.ts +++ b/packages/layout/src/generators.ts @@ -1,4 +1,4 @@ -import { Fixture, Layout, Topology } from './types'; +import { Fixture, Layout, RingSpec, Topology } from './types'; interface RawFixture { x: number; @@ -6,6 +6,8 @@ interface RawFixture { row: number; col: number; label: string; + /** Explicit concentric ring index; derived from the radius when omitted. */ + ring?: number; } interface FinalizeMeta { @@ -45,7 +47,7 @@ function finalize(raw: RawFixture[], meta: FinalizeMeta): Layout { y: f.y, angle: Math.atan2(f.y, f.x), radius: dists[i] / maxDist, - ring: Math.round(dists[i]), + ring: f.ring ?? Math.round(dists[i]), row: f.row, col: f.col, label: f.label @@ -208,3 +210,154 @@ export function filledRingLayout({ count, id, name }: FilledRingParams): Layout return layout; } + +export interface RingsParams { + rings: RingSpec[]; + id?: string; + name?: string; +} + +/** + * Concentric rings — the general round layout. One ring is a plain ring; a ring + * plus a smaller one inside it is an annulus (a ring with a hole in the middle); + * rings all the way in to a centre fixture is a symmetric disc. Radii are + * relative (only their ratios matter), a radius of 0 is the centre fixture. + * + * Fixtures are emitted outermost ring first, each clockwise from 12 o'clock, so + * shard slices and light maps stay contiguous per ring. There are no grid + * coordinates — `radius`/`angle`/`ring` are the meaningful axes here. + */ +export function ringsLayout({ rings, id, name }: RingsParams): Layout { + if (rings.length < 1) throw new Error('ringsLayout requires at least one ring'); + for (const ring of rings) { + if (!Number.isInteger(ring.count) || ring.count < 1) { + throw new Error(`ringsLayout requires an integer count >= 1 per ring, got ${ring.count}`); + } + if (!Number.isFinite(ring.radius) || ring.radius < 0) { + throw new Error(`ringsLayout requires a radius >= 0 per ring, got ${ring.radius}`); + } + if (ring.radius === 0 && ring.count !== 1) { + throw new Error('ringsLayout: the centre ring (radius 0) must have count 1'); + } + } + const radii = rings.map(r => r.radius); + if (new Set(radii).size !== radii.length) { + throw new Error('ringsLayout requires a distinct radius per ring'); + } + if (Math.max(...radii) <= 0) throw new Error('ringsLayout requires at least one ring with radius > 0'); + + // Outermost first; ring index counts from the inside out (0 = innermost). + const outerFirst = [...rings].sort((a, b) => b.radius - a.radius); + const ringIndexOf = (radius: number) => radii.filter(r => r < radius).length; + + const raw: RawFixture[] = []; + for (let k = 0; k < outerFirst.length; k++) { + const { count, radius, phase = 0 } = outerFirst[k]; + const offset = (phase * Math.PI) / 180; + for (let i = 0; i < count; i++) { + // Start at 12 o'clock, go clockwise — same convention as ringLayout. + const angle = -Math.PI / 2 + offset + (i / count) * Math.PI * 2; + raw.push({ + x: radius * Math.cos(angle), + y: radius * Math.sin(angle), + row: -1, + col: -1, + ring: ringIndexOf(radius), + label: `${k + 1}:${i + 1}` + }); + } + } + + const total = raw.length; + const counts = outerFirst.map(r => r.count).join('+'); + return finalize(raw, { + id: id ?? `rings-${counts}`, + name: name ?? `${total}-cannon rings (${counts})`, + topology: 'rings', + cols: 0, + rows: 0, + hasGridCoords: false, + perimeter: Array.from({ length: outerFirst[0].count }, (_, i) => i) + }); +} + +/** + * Spread `count` fixtures over concentric rings between `innerRadius` and the + * outer edge, keeping the spacing along a ring close to the spacing between + * rings. `innerRadius: 0` gives a symmetric disc (the innermost ring is the + * centre fixture); anything higher leaves a hole in the middle. + */ +export interface AnnulusParams { + count: number; + /** Radius of the hole, 0..1. Default 0.5. */ + innerRadius?: number; + id?: string; + name?: string; +} + +export function annulusLayout({ count, innerRadius = 0.5, id, name }: AnnulusParams): Layout { + if (!Number.isInteger(count) || count < 1) { + throw new Error(`annulusLayout requires an integer count >= 1, got ${count}`); + } + if (!Number.isFinite(innerRadius) || innerRadius < 0 || innerRadius >= 1) { + throw new Error(`annulusLayout requires 0 <= innerRadius < 1, got ${innerRadius}`); + } + + const hollow = innerRadius > 0; + const label = hollow ? 'annulus' : 'disc'; + return ringsLayout({ + rings: annulusRings(count, innerRadius), + id: id ?? `${label}-${count}`, + name: name ?? `${count}-cannon ${label}` + }); +} + +/** + * Pick the rings for an annulus: the area per fixture gives an ideal spacing, + * which fixes how many rings fit across the band; each ring then takes a share + * of the count proportional to its circumference. Alternate rings are staggered + * by half a step so fixtures interleave instead of lining up radially. + */ +function annulusRings(count: number, innerRadius: number): RingSpec[] { + const area = Math.PI * (1 - innerRadius * innerRadius); + const spacing = Math.sqrt(area / count); + const band = 1 - innerRadius; + const ringCount = Math.max(1, Math.min(count, Math.round(band / spacing) + 1)); + + const radii = ringCount === 1 + ? [1] + : Array.from({ length: ringCount }, (_, j) => 1 - (j * band) / (ringCount - 1)); + + const counts = share(count, radii); + return radii.map((radius, j) => ({ + radius, + count: counts[j], + phase: j % 2 === 1 ? 180 / counts[j] : 0 + })); +} + +/** Split `count` across rings in proportion to their radius, each ring >= 1. */ +function share(count: number, radii: number[]): number[] { + const sum = radii.reduce((a, r) => a + r, 0); + const exact = radii.map(r => (sum > 0 ? (count * r) / sum : 0)); + const alloc = exact.map(e => Math.max(1, Math.floor(e))); + + const total = () => alloc.reduce((a, n) => a + n, 0); + while (total() < count) { + let best = 0; + for (let i = 1; i < alloc.length; i++) { + if (exact[i] - alloc[i] > exact[best] - alloc[best]) best = i; + } + alloc[best]++; + } + while (total() > count) { + let best = -1; + for (let i = 0; i < alloc.length; i++) { + if (alloc[i] <= 1) continue; + if (best === -1 || alloc[i] - exact[i] > alloc[best] - exact[best]) best = i; + } + if (best === -1) break; + alloc[best]--; + } + return alloc; +} diff --git a/packages/layout/src/index.ts b/packages/layout/src/index.ts index be3dd39..8dc61a7 100644 --- a/packages/layout/src/index.ts +++ b/packages/layout/src/index.ts @@ -5,9 +5,11 @@ export type { Fb4Config, Fixture, Layout, + LayoutKind, LayoutSpec, OscConfig, ReceiverConfig, + RingSpec, RunMode, ServerConfig, ShardConfig, @@ -19,17 +21,24 @@ export type { // Generators export { + annulusLayout, + type AnnulusParams, filledRingLayout, type FilledRingParams, gridLayout, type GridParams, ringLayout, - type RingParams + type RingParams, + ringsLayout, + type RingsParams } from './generators'; // Presets + spec resolution export { getPresetNames, presets, resolveLayout } from './presets'; +// Human-writable layout shorthand ("annulus:25@0.4", "rings:12,8,4,1", …) +export { LAYOUT_SPEC_FORMS, parseLayoutSpec } from './layout-spec'; + // Light-map helpers (physical correction layer) + auto-map heuristics export { autoMap, diff --git a/packages/layout/src/layout-spec.ts b/packages/layout/src/layout-spec.ts new file mode 100644 index 0000000..0d949cd --- /dev/null +++ b/packages/layout/src/layout-spec.ts @@ -0,0 +1,95 @@ +import { getPresetNames, presets } from './presets'; +import type { LayoutSpec, RingSpec } from './types'; + +/** + * Human-writable layout shorthand, so a CLI flag or a single text field can + * describe any shape without hand-editing JSON: + * + * grid-7x7 a built-in preset id + * grid:7x7 cols × rows + * ring:6 one ring of 6 + * filled:25 disc masked out of a grid (keeps row/col) + * annulus:25 concentric rings with a hole in the middle + * annulus:25@0.35 …with the hole sized explicitly (0..1) + * rings:12,8,4,1 explicit counts, outermost first + */ +export const LAYOUT_SPEC_FORMS = [ + '', + 'grid:x', + 'ring:', + 'filled:', + 'annulus:[@]', + 'rings:,,…' +]; + +/** Radii for explicit ring counts: evenly spaced, and a lone inner ring is the centre. */ +function ringsFromCounts(counts: number[]): RingSpec[] { + const k = counts.length; + return counts.map((count, j) => { + const last = j === k - 1; + const radius = last && count === 1 ? 0 : (k - j) / k; + // Stagger alternate rings by half a step so fixtures interleave. + return { count, radius, phase: j % 2 === 1 ? 180 / count : 0 }; + }); +} + +function intOrThrow(text: string, what: string): number { + const n = Number(text); + if (!Number.isInteger(n) || n < 1) throw new Error(`${what} must be a whole number >= 1, got "${text}"`); + return n; +} + +/** + * Parse the shorthand above into a LayoutSpec. Throws with the accepted forms + * on anything unrecognized — callers can surface that message as-is. + */ +export function parseLayoutSpec(text: string): LayoutSpec { + const input = text.trim(); + if (!input) throw new Error(`Empty layout. Use one of: ${LAYOUT_SPEC_FORMS.join(' | ')}`); + + if (presets[input]) return { preset: input }; + + const colon = input.indexOf(':'); + if (colon === -1) { + throw new Error( + `Unknown layout "${input}". Presets: ${getPresetNames().join(', ')}. ` + + `Custom: ${LAYOUT_SPEC_FORMS.slice(1).join(' | ')}` + ); + } + + const kind = input.slice(0, colon).trim().toLowerCase(); + const rest = input.slice(colon + 1).trim(); + + switch (kind) { + case 'grid': { + const [colsText, rowsText, ...extra] = rest.split(/x/i); + if (rowsText == null || extra.length) throw new Error(`grid takes x, got "${rest}"`); + return { kind: 'grid', cols: intOrThrow(colsText, 'cols'), rows: intOrThrow(rowsText, 'rows') }; + } + case 'ring': + return { kind: 'ring', count: intOrThrow(rest, 'ring count') }; + case 'filled': + case 'filledring': + return { kind: 'filledRing', count: intOrThrow(rest, 'filled ring count') }; + case 'annulus': + case 'hollow': { + const [countText, innerText] = rest.split('@'); + const spec: LayoutSpec = { kind: 'annulus', count: intOrThrow(countText, 'annulus count') }; + if (innerText != null) { + const inner = Number(innerText); + if (!Number.isFinite(inner) || inner < 0 || inner >= 1) { + throw new Error(`annulus innerRadius must be 0 <= r < 1, got "${innerText}"`); + } + spec.innerRadius = inner; + } + return spec; + } + case 'rings': { + const counts = rest.split(',').map(part => intOrThrow(part, 'ring count')); + if (!counts.length) throw new Error('rings takes at least one count'); + return { kind: 'rings', rings: ringsFromCounts(counts) }; + } + default: + throw new Error(`Unknown layout kind "${kind}". Use one of: ${LAYOUT_SPEC_FORMS.join(' | ')}`); + } +} diff --git a/packages/layout/src/light-map.ts b/packages/layout/src/light-map.ts index 9059dfa..5b5bd03 100644 --- a/packages/layout/src/light-map.ts +++ b/packages/layout/src/light-map.ts @@ -90,6 +90,23 @@ function gridTransform( const isFullGrid = (l: Layout): boolean => l.hasGridCoords && l.cols * l.rows === l.count; const isSquareGrid = (l: Layout): boolean => isFullGrid(l) && l.cols === l.rows; +const isPolar = (l: Layout): boolean => !l.hasGridCoords && l.count > 1; +const ringCount = (l: Layout): number => new Set(l.fixtures.map((f) => f.ring)).size; + +/** Fixtures grouped by ring, outermost ring first, each in layout order. */ +function ringGroups(layout: Layout): number[][] { + const rings = [...new Set(layout.fixtures.map((f) => f.ring))].sort((a, b) => b - a); + return rings.map((ring) => layout.fixtures.filter((f) => f.ring === ring).map((f) => f.index)); +} + +/** Map each logical fixture to the physical slot at the same spot in `order`. */ +function reorder(layout: Layout, order: number[]): number[] { + const map = identityMap(layout.count); + order.forEach((logical, physical) => { + map[logical] = physical; + }); + return normalizeLightMap(map, layout.count); +} export const autoMapStrategies: AutoMapStrategy[] = [ { @@ -162,6 +179,20 @@ export const autoMapStrategies: AutoMapStrategy[] = [ description: 'Square grid mounted a quarter-turn counter-clockwise.', applies: isSquareGrid, build: (l) => gridTransform(l, (r, c, _rows, cols) => [cols - 1 - c, r]) + }, + { + id: 'ringCounterClockwise', + label: 'Rings wired counter-clockwise', + description: 'Each ring is wired the other way round from 12 o’clock.', + applies: isPolar, + build: (l) => reorder(l, ringGroups(l).flatMap((ring) => [ring[0], ...ring.slice(1).reverse()])) + }, + { + id: 'ringsInnerFirst', + label: 'Innermost ring first', + description: 'Wiring starts at the centre and works outwards.', + applies: (l) => isPolar(l) && ringCount(l) > 1, + build: (l) => reorder(l, ringGroups(l).reverse().flat()) } ]; diff --git a/packages/layout/src/presets.ts b/packages/layout/src/presets.ts index e3a93b2..0089e0a 100644 --- a/packages/layout/src/presets.ts +++ b/packages/layout/src/presets.ts @@ -1,4 +1,4 @@ -import { filledRingLayout, gridLayout, ringLayout } from './generators'; +import { annulusLayout, filledRingLayout, gridLayout, ringLayout, ringsLayout } from './generators'; import { Layout, LayoutSpec } from './types'; /** @@ -10,16 +10,22 @@ export const presets: Record Layout> = { 'grid-7x2': () => gridLayout({ cols: 7, rows: 2, id: 'grid-7x2', name: '7×2 grid (14)' }), 'ring-6': () => ringLayout({ count: 6, id: 'ring-6', name: '6-cannon ring' }), nova: () => ringLayout({ count: 6, id: 'nova', name: 'Nova (6-laser ring)' }), - 'ring-25-filled': () => filledRingLayout({ count: 25, id: 'ring-25-filled', name: '25-cannon filled ring' }) + 'ring-25-filled': () => filledRingLayout({ count: 25, id: 'ring-25-filled', name: '25-cannon filled ring' }), + 'ring-25-hollow': () => annulusLayout({ count: 25, innerRadius: 0.5, id: 'ring-25-hollow', name: '25-cannon ring with a hollow centre' }), + 'disc-25': () => annulusLayout({ count: 25, innerRadius: 0, id: 'disc-25', name: '25-cannon disc (concentric rings)' }) }; export function getPresetNames(): string[] { return Object.keys(presets); } -/** Build a Layout from a spec (preset id, or a generator kind + params). */ +/** + * Build a Layout from a spec (preset id, or a generator kind + params). An + * explicit `kind` wins over `preset`, because the default config carries a + * preset that a project's custom shape is merged on top of. + */ export function resolveLayout(spec: LayoutSpec): Layout { - if (spec.preset) { + if (spec.preset && !spec.kind) { const make = presets[spec.preset]; if (!make) { throw new Error( @@ -44,6 +50,12 @@ export function resolveLayout(spec: LayoutSpec): Layout { case 'filledRing': if (spec.count == null) throw new Error('filledRing layout requires "count"'); return filledRingLayout({ count: spec.count, id: spec.id, name: spec.name }); + case 'rings': + if (!spec.rings?.length) throw new Error('rings layout requires a non-empty "rings" list'); + return ringsLayout({ rings: spec.rings, id: spec.id, name: spec.name }); + case 'annulus': + if (spec.count == null) throw new Error('annulus layout requires "count"'); + return annulusLayout({ count: spec.count, innerRadius: spec.innerRadius, id: spec.id, name: spec.name }); default: throw new Error('layout spec must set either "preset" or "kind"'); } diff --git a/packages/layout/src/types.ts b/packages/layout/src/types.ts index b6fe42f..e94ebd5 100644 --- a/packages/layout/src/types.ts +++ b/packages/layout/src/types.ts @@ -9,7 +9,19 @@ import type { UnifiedRouting } from './routing'; -export type Topology = 'grid' | 'ring' | 'filledRing'; +export type Topology = 'grid' | 'ring' | 'filledRing' | 'rings'; + +/** + * One concentric ring of a `rings` layout. `radius` is 0..1 (1 = outermost); + * `phase` rotates the ring in degrees so its fixtures can sit between the ones + * outside it instead of lining up radially. A ring at radius 0 is the centre + * fixture and must have `count: 1`. + */ +export interface RingSpec { + count: number; + radius: number; + phase?: number; +} /** * A single cannon's position. `index` is the logical id used everywhere else @@ -63,17 +75,27 @@ export interface Layout { export type RunMode = 'simple' | 'distributed'; +/** + * Layout kinds a config can ask for. `annulus` is sugar: it distributes a + * fixture count over concentric rings and resolves to a `rings` layout. + */ +export type LayoutKind = Topology | 'annulus'; + /** How a layout is described in a config file. */ export interface LayoutSpec { /** Reference a built-in preset by id (takes precedence over `kind`). */ preset?: string; - kind?: Topology; + kind?: LayoutKind; /** grid: number of columns. */ cols?: number; /** grid: number of rows. */ rows?: number; - /** ring / filledRing: number of cannons. */ + /** ring / filledRing / annulus: number of cannons. */ count?: number; + /** rings: the concentric rings, in any order (outer-first is the result). */ + rings?: RingSpec[]; + /** annulus: radius of the hole in the middle, 0..1 (0 = solid disc). */ + innerRadius?: number; /** Override the generated id/name. */ id?: string; name?: string;