diff --git a/packages/factory/scripts/generate-placeholder-pngs.ts b/packages/factory/scripts/generate-placeholder-pngs.ts index 5488afef..08ef7f8e 100644 --- a/packages/factory/scripts/generate-placeholder-pngs.ts +++ b/packages/factory/scripts/generate-placeholder-pngs.ts @@ -10,32 +10,19 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -const SPRITE_SIZE = 32; -const COLS = 4; -const ROWS = 3; -const WIDTH = SPRITE_SIZE * COLS; -const HEIGHT = SPRITE_SIZE * ROWS; - -function generatePlaceholderSvg(color: string, label: string): string { - const frames: string[] = []; - for (let row = 0; row < ROWS; row++) { - for (let col = 0; col < COLS; col++) { - const x = col * SPRITE_SIZE; - const y = row * SPRITE_SIZE; - const frameNum = row * COLS + col; - frames.push( - ``, - `${label}${frameNum}`, - ); - } - } - return `${frames.join('')}`; -} +import { ORCHESTRATOR_POSES } from './sprites/orchestrator-poses.ts'; +import { ORCHESTRATOR_PALETTE, SUBAGENT_PALETTE } from './sprites/palettes.ts'; +import { SUBAGENT_POSES } from './sprites/subagent-poses.ts'; +import { renderSpriteSheet } from './sprites/svg-renderer.ts'; const outDir = join(import.meta.dirname, '../src/client/visualizations/catwalk/sprites/assets'); mkdirSync(outDir, { recursive: true }); -writeFileSync(join(outDir, 'subagent.svg'), generatePlaceholderSvg('#888899', 'S')); -writeFileSync(join(outDir, 'orchestrator.svg'), generatePlaceholderSvg('#CCAA44', 'O')); +// Generate both SVG strings in memory before writing either to disk +const subagentSvg = renderSpriteSheet(SUBAGENT_POSES, SUBAGENT_PALETTE); +const orchestratorSvg = renderSpriteSheet(ORCHESTRATOR_POSES, ORCHESTRATOR_PALETTE); + +writeFileSync(join(outDir, 'subagent.svg'), subagentSvg); +writeFileSync(join(outDir, 'orchestrator.svg'), orchestratorSvg); -console.info('Placeholder sprite sheets written to', outDir); +console.info('Sprite sheets written to', outDir); diff --git a/packages/factory/scripts/sprites/__tests__/svg-renderer.test.ts b/packages/factory/scripts/sprites/__tests__/svg-renderer.test.ts new file mode 100644 index 00000000..ebc98694 --- /dev/null +++ b/packages/factory/scripts/sprites/__tests__/svg-renderer.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, it } from 'vitest'; + +import { ORCHESTRATOR_POSES } from '../orchestrator-poses.ts'; +import { ORCHESTRATOR_PALETTE, SUBAGENT_PALETTE } from '../palettes.ts'; +import { SUBAGENT_POSES } from '../subagent-poses.ts'; +import type { BodyPart, Palette, Pose } from '../svg-renderer.ts'; +import { composePose, renderSpriteSheet } from '../svg-renderer.ts'; + +// ── composePose ───────────────────────────────────────────────────────────── + +describe('composePose', () => { + it('places a single body part at a known offset', () => { + const part: BodyPart = { + pixels: [ + [1, 2], + [3, 0], + ], + offsetX: 5, + offsetY: 10, + }; + const grid = composePose([part], 32); + + expect(grid[10]?.[5]).toBe(1); + expect(grid[10]?.[6]).toBe(2); + expect(grid[11]?.[5]).toBe(3); + // Pixel value 0 leaves the grid at 0 (transparent) + expect(grid[11]?.[6]).toBe(0); + }); + + it('silently clips pixels that extend beyond the grid boundary', () => { + const part: BodyPart = { + pixels: [[1, 2, 3]], + offsetX: 30, + offsetY: 0, + }; + const grid = composePose([part], 32); + + // Only the first two pixels fit (indices 30 and 31) + expect(grid[0]?.[30]).toBe(1); + expect(grid[0]?.[31]).toBe(2); + // Index 32 is out of bounds -- the grid only has 32 columns (0-31) + expect(grid[0]?.length).toBe(32); + }); + + it('clips pixels with negative offsets', () => { + const part: BodyPart = { + pixels: [[1, 2, 3]], + offsetX: -1, + offsetY: 0, + }; + const grid = composePose([part], 32); + + // offsetX=-1: pixel at px=0 maps to gx=-1 (clipped), px=1 maps to gx=0, px=2 maps to gx=1 + expect(grid[0]?.[0]).toBe(2); + expect(grid[0]?.[1]).toBe(3); + }); + + it('applies painter algorithm: later body parts overwrite earlier ones', () => { + const bottom: BodyPart = { + pixels: [[1]], + offsetX: 5, + offsetY: 5, + }; + const top: BodyPart = { + pixels: [[2]], + offsetX: 5, + offsetY: 5, + }; + const grid = composePose([bottom, top], 32); + + // The second (top) body part should win + expect(grid[5]?.[5]).toBe(2); + }); + + it('transparent pixels (value 0) do not overwrite previously placed pixels', () => { + const bottom: BodyPart = { + pixels: [[3]], + offsetX: 5, + offsetY: 5, + }; + const top: BodyPart = { + pixels: [[0]], + offsetX: 5, + offsetY: 5, + }; + const grid = composePose([bottom, top], 32); + + // The top part has 0 (transparent), so the bottom part's value should remain + expect(grid[5]?.[5]).toBe(3); + }); + + it('returns a grid of the requested size initialized to zeros', () => { + const grid = composePose([], 16); + + expect(grid).toHaveLength(16); + for (const row of grid) { + expect(row).toHaveLength(16); + for (const cell of row) { + expect(cell).toBe(0); + } + } + }); +}); + +// ── renderSpriteSheet ─────────────────────────────────────────────────────── + +describe('renderSpriteSheet', () => { + const palette: Palette = ['', '#111', '#222', '#333', '#444', '#555']; + + it('produces an SVG with correct 128x96 dimensions', () => { + const poses: Pose[] = Array.from({ length: 12 }, () => []); + const svg = renderSpriteSheet(poses, palette); + + expect(svg).toContain('xmlns="http://www.w3.org/2000/svg"'); + expect(svg).toContain('width="128"'); + expect(svg).toContain('height="96"'); + }); + + it('starts and ends with proper SVG tags', () => { + const poses: Pose[] = Array.from({ length: 12 }, () => []); + const svg = renderSpriteSheet(poses, palette); + + expect(svg).toMatch(/^$/); + }); + + it('renders empty poses as an SVG with no rect elements', () => { + const poses: Pose[] = Array.from({ length: 12 }, () => []); + const svg = renderSpriteSheet(poses, palette); + + expect(svg).not.toContain(' { + const part: BodyPart = { pixels: [[1]], offsetX: 3, offsetY: 7 }; + const poses: Pose[] = Array.from({ length: 12 }, (_, i) => (i === 0 ? [part] : [])); + const svg = renderSpriteSheet(poses, palette); + + // Frame 0: col=0, row=0 -> frameX=0, frameY=0 + // Pixel at grid position (3, 7) -> SVG x=0+3=3, y=0+7=7 + expect(svg).toContain('x="3" y="7" width="1" height="1" fill="#111"'); + }); + + it('places frame 1 pixels at column offset (32, 0)', () => { + const part: BodyPart = { pixels: [[2]], offsetX: 0, offsetY: 0 }; + const poses: Pose[] = Array.from({ length: 12 }, (_, i) => (i === 1 ? [part] : [])); + const svg = renderSpriteSheet(poses, palette); + + // Frame 1: col=1, row=0 -> frameX=32, frameY=0 + expect(svg).toContain('x="32" y="0" width="1" height="1" fill="#222"'); + }); + + it('places frame 4 pixels at row offset (0, 32)', () => { + const part: BodyPart = { pixels: [[3]], offsetX: 0, offsetY: 0 }; + const poses: Pose[] = Array.from({ length: 12 }, (_, i) => (i === 4 ? [part] : [])); + const svg = renderSpriteSheet(poses, palette); + + // Frame 4: col=0, row=1 -> frameX=0, frameY=32 + expect(svg).toContain('x="0" y="32" width="1" height="1" fill="#333"'); + }); + + it('places frame 5 pixels at offset (32, 32)', () => { + const part: BodyPart = { pixels: [[4]], offsetX: 1, offsetY: 2 }; + const poses: Pose[] = Array.from({ length: 12 }, (_, i) => (i === 5 ? [part] : [])); + const svg = renderSpriteSheet(poses, palette); + + // Frame 5: col=1, row=1 -> frameX=32, frameY=32 + // Pixel at (1,2) -> SVG x=32+1=33, y=32+2=34 + expect(svg).toContain('x="33" y="34" width="1" height="1" fill="#444"'); + }); + + it('skips transparent pixels (palette index 0)', () => { + const part: BodyPart = { + pixels: [[0, 1]], + offsetX: 0, + offsetY: 0, + }; + const poses: Pose[] = Array.from({ length: 12 }, (_, i) => (i === 0 ? [part] : [])); + const svg = renderSpriteSheet(poses, palette); + + // Only one rect for palette index 1, none for palette index 0 + const rectCount = (svg.match(/ { + const part: BodyPart = { + pixels: [[1, 2, 3, 4, 5]], + offsetX: 0, + offsetY: 0, + }; + const poses: Pose[] = Array.from({ length: 12 }, (_, i) => (i === 0 ? [part] : [])); + const svg = renderSpriteSheet(poses, palette); + + expect(svg).toContain('fill="#111"'); + expect(svg).toContain('fill="#222"'); + expect(svg).toContain('fill="#333"'); + expect(svg).toContain('fill="#444"'); + expect(svg).toContain('fill="#555"'); + }); + + it('throws when pose count does not equal 12', () => { + const tooPoses: Pose[] = Array.from({ length: 10 }, () => []); + + expect(() => renderSpriteSheet(tooPoses, palette)).toThrow(/expected exactly 12 poses/i); + }); + + it('warns when a pixel references an out-of-range palette index', () => { + const part: BodyPart = { + pixels: [[6]], // palette only has indices 0-5 + offsetX: 0, + offsetY: 0, + }; + const poses: Pose[] = Array.from({ length: 12 }, (_, i) => (i === 0 ? [part] : [])); + + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args.map(String).join(' ')); + }; + + try { + const svg = renderSpriteSheet(poses, palette); + // The pixel should be skipped (no rect emitted) + expect(svg).not.toContain(' { + it('SUBAGENT_POSES has exactly 12 poses', () => { + expect(SUBAGENT_POSES).toHaveLength(12); + }); + + it('ORCHESTRATOR_POSES has exactly 12 poses', () => { + expect(ORCHESTRATOR_POSES).toHaveLength(12); + }); + + it('every subagent pose has at least one body part', () => { + for (const [i, pose] of SUBAGENT_POSES.entries()) { + expect(pose.length, `pose ${i} has no body parts`).toBeGreaterThan(0); + } + }); + + it('every orchestrator pose has at least one body part', () => { + for (const [i, pose] of ORCHESTRATOR_POSES.entries()) { + expect(pose.length, `pose ${i} has no body parts`).toBeGreaterThan(0); + } + }); + + it('all subagent pixel values are within the valid palette range [0, 5]', () => { + for (const [poseIndex, pose] of SUBAGENT_POSES.entries()) { + for (const [partIndex, part] of pose.entries()) { + for (const [rowIndex, row] of part.pixels.entries()) { + for (const [colIndex, value] of row.entries()) { + expect( + value, + `subagent pose ${poseIndex}, part ${partIndex}, row ${rowIndex}, col ${colIndex}: value ${value} out of range`, + ).toBeGreaterThanOrEqual(0); + expect( + value, + `subagent pose ${poseIndex}, part ${partIndex}, row ${rowIndex}, col ${colIndex}: value ${value} out of range`, + ).toBeLessThanOrEqual(5); + } + } + } + } + }); + + it('all orchestrator pixel values are within the valid palette range [0, 5]', () => { + for (const [poseIndex, pose] of ORCHESTRATOR_POSES.entries()) { + for (const [partIndex, part] of pose.entries()) { + for (const [rowIndex, row] of part.pixels.entries()) { + for (const [colIndex, value] of row.entries()) { + expect( + value, + `orchestrator pose ${poseIndex}, part ${partIndex}, row ${rowIndex}, col ${colIndex}: value ${value} out of range`, + ).toBeGreaterThanOrEqual(0); + expect( + value, + `orchestrator pose ${poseIndex}, part ${partIndex}, row ${rowIndex}, col ${colIndex}: value ${value} out of range`, + ).toBeLessThanOrEqual(5); + } + } + } + } + }); + + it('subagent poses produce valid SVG output', () => { + const svg = renderSpriteSheet(SUBAGENT_POSES, SUBAGENT_PALETTE); + + expect(svg).toContain('width="128"'); + expect(svg).toContain('height="96"'); + expect(svg).toContain(' { + const svg = renderSpriteSheet(ORCHESTRATOR_POSES, ORCHESTRATOR_PALETTE); + + expect(svg).toContain('width="128"'); + expect(svg).toContain('height="96"'); + expect(svg).toContain('>; + +/** A body part: a pixel grid with an (x, y) offset into the 32x32 frame. */ +export interface BodyPart { + pixels: PixelGrid; + offsetX: number; + offsetY: number; +} + +/** An animation frame: an ordered list of body parts composited with the painter's algorithm. */ +export type Pose = ReadonlyArray; + +/** Maps palette indices (1-5) to hex color strings; index 0 is transparent. */ +export type Palette = readonly [transparent: '', ...colors: string[]]; + +const SPRITE_SIZE = 32; +const COLS = 4; +const ROWS = 3; + +/** Composites body parts onto a size x size grid using the painter's algorithm. */ +export function composePose(pose: Pose, size: number): number[][] { + const grid: number[][] = []; + for (let y = 0; y < size; y++) { + const row: number[] = []; + for (let x = 0; x < size; x++) { + row.push(0); + } + grid.push(row); + } + + for (const part of pose) { + const { pixels, offsetX, offsetY } = part; + for (const [py, pixelRow] of pixels.entries()) { + for (const [px, value] of pixelRow.entries()) { + if (value === 0) continue; + + const gx = offsetX + px; + const gy = offsetY + py; + + if (gx < 0 || gx >= size || gy < 0 || gy >= size) continue; + + const gridRow = grid[gy]; + if (gridRow === undefined) continue; + gridRow[gx] = value; + } + } + } + + return grid; +} + +/** Renders exactly 12 poses into a 128x96 SVG sprite sheet with 4 columns and 3 rows. */ +export function renderSpriteSheet(poses: Pose[], palette: Palette): string { + const expectedCount = ROWS * COLS; + if (poses.length !== expectedCount) { + throw new Error(`Expected exactly 12 poses but received ${poses.length}`); + } + + const width = SPRITE_SIZE * COLS; + const height = SPRITE_SIZE * ROWS; + const rects: string[] = []; + + for (const [frameIndex, pose] of poses.entries()) { + const frameX = (frameIndex % COLS) * SPRITE_SIZE; + const frameY = Math.floor(frameIndex / COLS) * SPRITE_SIZE; + const grid = composePose(pose, SPRITE_SIZE); + + for (const [y, row] of grid.entries()) { + for (const [x, paletteIndex] of row.entries()) { + if (paletteIndex === 0) continue; + + const color = palette[paletteIndex]; + if (color === undefined) { + console.warn(`Palette index ${paletteIndex} out of range in frame ${frameIndex} at pixel (${x}, ${y})`); + continue; + } + if (color === '') continue; + + rects.push(``); + } + } + } + + return `${rects.join('')}`; +} diff --git a/packages/factory/src/client/visualizations/catwalk/sprites/assets/orchestrator.svg b/packages/factory/src/client/visualizations/catwalk/sprites/assets/orchestrator.svg index 4d3b88d0..50c4a194 100644 --- a/packages/factory/src/client/visualizations/catwalk/sprites/assets/orchestrator.svg +++ b/packages/factory/src/client/visualizations/catwalk/sprites/assets/orchestrator.svg @@ -1 +1 @@ -O0O1O2O3O4O5O6O7O8O9O10O11 \ No newline at end of file + \ No newline at end of file diff --git a/packages/factory/src/client/visualizations/catwalk/sprites/assets/subagent.svg b/packages/factory/src/client/visualizations/catwalk/sprites/assets/subagent.svg index 07fb73e8..1a85b41b 100644 --- a/packages/factory/src/client/visualizations/catwalk/sprites/assets/subagent.svg +++ b/packages/factory/src/client/visualizations/catwalk/sprites/assets/subagent.svg @@ -1 +1 @@ -S0S1S2S3S4S5S6S7S8S9S10S11 \ No newline at end of file + \ No newline at end of file diff --git a/packages/factory/tsconfig.json b/packages/factory/tsconfig.json index d91325de..312ec042 100644 --- a/packages/factory/tsconfig.json +++ b/packages/factory/tsconfig.json @@ -7,5 +7,5 @@ "strict": true, "useDefineForClassFields": true, }, - "include": ["src/", "*.ts"], + "include": ["src/", "scripts/", "*.ts"], } diff --git a/packages/factory/vitest.config.ts b/packages/factory/vitest.config.ts index bc53a525..77ae538d 100644 --- a/packages/factory/vitest.config.ts +++ b/packages/factory/vitest.config.ts @@ -8,7 +8,7 @@ const config = defineConfig({ test: { coverage: { include: ['src/**/*.{ts,tsx}'] }, environment: 'jsdom', - include: ['src/**/__tests__/*.test.{ts,tsx}'], + include: ['src/**/__tests__/*.test.{ts,tsx}', 'scripts/**/__tests__/*.test.ts'], setupFiles: ['vitest.setup.ts'], }, });