From 7e565e405e1ab0a7ee82ac18cfb3d29b8e3dc258 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Sun, 8 Mar 2026 20:14:38 -0700 Subject: [PATCH 1/2] factory|feat: Replace placeholder rectangles with block-robot pixel art sprites Create a composable sprite generation module under scripts/sprites/ with types, palettes, body-part definitions for subagent and orchestrator robots, and an SVG renderer. Each robot is built from reusable BodyPart constants assembled into 12 animation poses matching the sprite-sheet frame layout. The subagent is a compact ~14x22px worker robot; the orchestrator is a taller ~16x24px robot with a distinctive antenna. Update generate-placeholder-pngs.ts to use the new module and extend tsconfig.json to include scripts/ for typechecking. --- .../scripts/generate-placeholder-pngs.ts | 33 +- .../scripts/sprites/orchestrator-poses.ts | 298 ++++++++++++++++++ packages/factory/scripts/sprites/palettes.ts | 7 + .../factory/scripts/sprites/subagent-poses.ts | 269 ++++++++++++++++ .../factory/scripts/sprites/svg-renderer.ts | 77 +++++ .../catwalk/sprites/assets/orchestrator.svg | 2 +- .../catwalk/sprites/assets/subagent.svg | 2 +- packages/factory/tsconfig.json | 2 +- 8 files changed, 664 insertions(+), 26 deletions(-) create mode 100644 packages/factory/scripts/sprites/orchestrator-poses.ts create mode 100644 packages/factory/scripts/sprites/palettes.ts create mode 100644 packages/factory/scripts/sprites/subagent-poses.ts create mode 100644 packages/factory/scripts/sprites/svg-renderer.ts diff --git a/packages/factory/scripts/generate-placeholder-pngs.ts b/packages/factory/scripts/generate-placeholder-pngs.ts index 5488afef..327769b1 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); diff --git a/packages/factory/scripts/sprites/orchestrator-poses.ts b/packages/factory/scripts/sprites/orchestrator-poses.ts new file mode 100644 index 00000000..8df5eedb --- /dev/null +++ b/packages/factory/scripts/sprites/orchestrator-poses.ts @@ -0,0 +1,298 @@ +import type { BodyPart, Pose } from './svg-renderer.ts'; + +// ── Body parts ────────────────────────────────────────────────────────────── + +// 1x3 antenna above head center +const ANTENNA: BodyPart = { + pixels: [[5], [4], [3]], + offsetX: 15, + offsetY: 0, +}; + +// 10x7 wider head with visor strip at row 3 +const HEAD: BodyPart = { + pixels: [ + [5, 5, 4, 4, 4, 4, 4, 4, 3, 3], + [5, 3, 3, 3, 3, 3, 3, 3, 3, 1], + [5, 3, 3, 3, 3, 3, 3, 3, 3, 1], + [5, 5, 5, 5, 5, 5, 5, 5, 5, 1], + [5, 5, 5, 5, 5, 5, 5, 5, 5, 1], + [5, 3, 3, 3, 3, 3, 3, 3, 3, 1], + [1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + ], + offsetX: 11, + offsetY: 3, +}; + +// 12x9 broader torso with shading +const TORSO: BodyPart = { + pixels: [ + [5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 3, 3], + [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 1], + [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 1], + [5, 3, 3, 4, 4, 5, 4, 4, 3, 3, 2, 1], + [5, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 1], + [5, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1], + [5, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1], + [5, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1], + [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + ], + offsetX: 10, + offsetY: 10, +}; + +// 2x6 arm hanging at side (left) +const ARM_LEFT_DOWN: BodyPart = { + pixels: [ + [4, 3], + [3, 2], + [3, 2], + [3, 2], + [3, 2], + [1, 1], + ], + offsetX: 8, + offsetY: 11, +}; + +// 2x6 arm hanging at side (right) +const ARM_RIGHT_DOWN: BodyPart = { + pixels: [ + [3, 4], + [2, 3], + [2, 3], + [2, 3], + [2, 3], + [1, 1], + ], + offsetX: 22, + offsetY: 11, +}; + +// 2x6 arm raised partway (left) +const ARM_LEFT_UP: BodyPart = { + pixels: [ + [4, 3], + [3, 2], + [3, 2], + [3, 2], + [3, 2], + [1, 1], + ], + offsetX: 8, + offsetY: 7, +}; + +// 2x6 arm raised partway (right) +const ARM_RIGHT_UP: BodyPart = { + pixels: [ + [3, 4], + [2, 3], + [2, 3], + [2, 3], + [2, 3], + [1, 1], + ], + offsetX: 22, + offsetY: 7, +}; + +// 2x6 arm raised high (left, for celebrating) +const ARM_LEFT_RAISED_HIGH: BodyPart = { + pixels: [ + [4, 3], + [3, 2], + [3, 2], + [3, 2], + [3, 2], + [1, 1], + ], + offsetX: 8, + offsetY: 3, +}; + +// 2x6 arm raised high (right, for celebrating) +const ARM_RIGHT_RAISED_HIGH: BodyPart = { + pixels: [ + [3, 4], + [2, 3], + [2, 3], + [2, 3], + [2, 3], + [1, 1], + ], + offsetX: 22, + offsetY: 3, +}; + +// 2x6 arm raised high and splayed out (left, for V-shape celebrating) +const ARM_LEFT_SPLAYED: BodyPart = { + pixels: [ + [4, 3], + [3, 2], + [3, 2], + [3, 2], + [3, 2], + [1, 1], + ], + offsetX: 6, + offsetY: 3, +}; + +// 2x6 arm raised high and splayed out (right, for V-shape celebrating) +const ARM_RIGHT_SPLAYED: BodyPart = { + pixels: [ + [3, 4], + [2, 3], + [2, 3], + [2, 3], + [2, 3], + [1, 1], + ], + offsetX: 24, + offsetY: 3, +}; + +// 6x2 arm extended horizontally (left) +const ARM_LEFT_EXTENDED: BodyPart = { + pixels: [ + [4, 3, 3, 3, 3, 1], + [1, 2, 2, 2, 2, 1], + ], + offsetX: 4, + offsetY: 12, +}; + +// 6x2 arm extended horizontally (right) +const ARM_RIGHT_EXTENDED: BodyPart = { + pixels: [ + [1, 3, 3, 3, 3, 4], + [1, 2, 2, 2, 2, 1], + ], + offsetX: 22, + offsetY: 12, +}; + +// Legs standing straight (pair), wider stance for orchestrator +const LEG_STAND: BodyPart = { + pixels: [ + [0, 3, 0, 0, 0, 0, 0, 3, 0], + [0, 3, 0, 0, 0, 0, 0, 3, 0], + [0, 2, 0, 0, 0, 0, 0, 2, 0], + [0, 2, 0, 0, 0, 0, 0, 2, 0], + [1, 1, 1, 0, 0, 0, 1, 1, 1], + ], + offsetX: 11, + offsetY: 19, +}; + +// Legs with left leg forward, right leg back (walking mid-stride) +const LEG_FORWARD: BodyPart = { + pixels: [ + [0, 3, 0, 0, 0, 0, 0, 0, 3], + [3, 2, 0, 0, 0, 0, 0, 0, 2], + [2, 2, 0, 0, 0, 0, 0, 0, 2], + [1, 1, 0, 0, 0, 0, 0, 0, 2], + [0, 0, 0, 0, 0, 0, 0, 1, 1], + ], + offsetX: 10, + offsetY: 19, +}; + +// Legs with slight outward splay (for resting) +const LEG_RELAXED: BodyPart = { + pixels: [ + [3, 0, 0, 0, 0, 0, 0, 0, 3], + [3, 0, 0, 0, 0, 0, 0, 0, 3], + [2, 0, 0, 0, 0, 0, 0, 0, 2], + [2, 0, 0, 0, 0, 0, 0, 0, 2], + [1, 1, 0, 0, 0, 0, 0, 1, 1], + ], + offsetX: 11, + offsetY: 19, +}; + +// Arms pulled in close (for concerned) +const ARM_LEFT_PULLED_IN: BodyPart = { + pixels: [ + [4, 3], + [3, 2], + [3, 2], + [3, 2], + [1, 1], + ], + offsetX: 9, + offsetY: 12, +}; + +const ARM_RIGHT_PULLED_IN: BodyPart = { + pixels: [ + [3, 4], + [2, 3], + [2, 3], + [2, 3], + [1, 1], + ], + offsetX: 21, + offsetY: 12, +}; + +// Head shifted down 1px (for slouching/resting) +const HEAD_SLOUCH: BodyPart = { + ...HEAD, + offsetY: 4, +}; + +// Antenna shifted down 1px to match slouch +const ANTENNA_SLOUCH: BodyPart = { + ...ANTENNA, + offsetY: 1, +}; + +// ── 12 poses in sprite-sheet order ────────────────────────────────────────── + +/** Twelve animation poses for the orchestrator robot, ordered by sprite-sheet frame index. */ +export const ORCHESTRATOR_POSES: Pose[] = [ + // Frame 0: Idle 1 — standing, arms down + [LEG_STAND, TORSO, ARM_LEFT_DOWN, ARM_RIGHT_DOWN, HEAD, ANTENNA], + + // Frame 1: Idle 2 — arms shifted 1px (ping-pong variation) + [ + LEG_STAND, + TORSO, + { ...ARM_LEFT_DOWN, offsetX: ARM_LEFT_DOWN.offsetX - 1 }, + { ...ARM_RIGHT_DOWN, offsetX: ARM_RIGHT_DOWN.offsetX + 1 }, + HEAD, + ANTENNA, + ], + + // Frame 2: Walking — mid-stride with LEG_FORWARD + [LEG_FORWARD, TORSO, ARM_LEFT_UP, ARM_RIGHT_DOWN, HEAD, ANTENNA], + + // Frame 3: Resting 1 — relaxed stance + [LEG_RELAXED, TORSO, ARM_LEFT_DOWN, ARM_RIGHT_DOWN, HEAD, ANTENNA], + + // Frame 4: Working 1 — both arms raised + [LEG_STAND, TORSO, ARM_LEFT_UP, ARM_RIGHT_UP, HEAD, ANTENNA], + + // Frame 5: Working 2 — arms extended horizontally + [LEG_STAND, TORSO, ARM_LEFT_EXTENDED, ARM_RIGHT_EXTENDED, HEAD, ANTENNA], + + // Frame 6: Working 3 — arms lowering back (one up, one down) + [LEG_STAND, TORSO, ARM_LEFT_UP, ARM_RIGHT_DOWN, HEAD, ANTENNA], + + // Frame 7: Resting 2 — slight slouch, head offset 1px down + [LEG_RELAXED, TORSO, ARM_LEFT_DOWN, ARM_RIGHT_DOWN, HEAD_SLOUCH, ANTENNA_SLOUCH], + + // Frame 8: Celebrating 1 — both arms raised high + [LEG_STAND, TORSO, ARM_LEFT_RAISED_HIGH, ARM_RIGHT_RAISED_HIGH, HEAD, ANTENNA], + + // Frame 9: Celebrating 2 — arms in V-shape (splayed) + [LEG_STAND, TORSO, ARM_LEFT_SPLAYED, ARM_RIGHT_SPLAYED, HEAD, ANTENNA], + + // Frame 10: Concerned — hunched, arms pulled in + [LEG_STAND, TORSO, ARM_LEFT_PULLED_IN, ARM_RIGHT_PULLED_IN, HEAD_SLOUCH, ANTENNA_SLOUCH], + + // Frame 11: Resting 3 — powered-down, arms at sides + [LEG_RELAXED, TORSO, ARM_LEFT_DOWN, ARM_RIGHT_DOWN, HEAD, ANTENNA], +]; diff --git a/packages/factory/scripts/sprites/palettes.ts b/packages/factory/scripts/sprites/palettes.ts new file mode 100644 index 00000000..f34147e3 --- /dev/null +++ b/packages/factory/scripts/sprites/palettes.ts @@ -0,0 +1,7 @@ +import type { Palette } from './svg-renderer.ts'; + +/** Neutral metallic palette for the subagent robot. */ +export const SUBAGENT_PALETTE: Palette = ['', '#2a2a3e', '#4a4a6e', '#7777aa', '#9999cc', '#bbbbee']; + +/** Gold command-unit palette for the orchestrator robot. */ +export const ORCHESTRATOR_PALETTE: Palette = ['', '#3a2a0e', '#6e5a2e', '#aa8844', '#ccaa44', '#eedd88']; diff --git a/packages/factory/scripts/sprites/subagent-poses.ts b/packages/factory/scripts/sprites/subagent-poses.ts new file mode 100644 index 00000000..09984ba3 --- /dev/null +++ b/packages/factory/scripts/sprites/subagent-poses.ts @@ -0,0 +1,269 @@ +import type { BodyPart, Pose } from './svg-renderer.ts'; + +// ── Body parts ────────────────────────────────────────────────────────────── + +// 8x6 boxy head with 2-pixel visor strip at row 2 +const HEAD: BodyPart = { + pixels: [ + [5, 5, 4, 4, 4, 4, 3, 3], + [5, 3, 3, 3, 3, 3, 3, 1], + [5, 5, 5, 5, 5, 5, 5, 1], + [5, 5, 5, 5, 5, 5, 5, 1], + [5, 3, 3, 3, 3, 3, 3, 1], + [1, 1, 1, 1, 1, 1, 1, 1], + ], + offsetX: 12, + offsetY: 4, +}; + +// 10x8 rectangular torso with shadow on bottom/right edges +const TORSO: BodyPart = { + pixels: [ + [5, 4, 4, 4, 4, 4, 4, 4, 3, 3], + [5, 3, 3, 3, 3, 3, 3, 3, 2, 1], + [5, 3, 3, 3, 3, 3, 3, 3, 2, 1], + [5, 3, 3, 4, 4, 4, 3, 3, 2, 1], + [5, 3, 3, 3, 3, 3, 3, 3, 2, 1], + [5, 2, 2, 2, 2, 2, 2, 2, 2, 1], + [5, 2, 2, 2, 2, 2, 2, 2, 2, 1], + [1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + ], + offsetX: 11, + offsetY: 10, +}; + +// 2x5 arm hanging at side (left) +const ARM_LEFT_DOWN: BodyPart = { + pixels: [ + [4, 3], + [3, 2], + [3, 2], + [3, 2], + [1, 1], + ], + offsetX: 9, + offsetY: 11, +}; + +// 2x5 arm hanging at side (right) +const ARM_RIGHT_DOWN: BodyPart = { + pixels: [ + [3, 4], + [2, 3], + [2, 3], + [2, 3], + [1, 1], + ], + offsetX: 21, + offsetY: 11, +}; + +// 2x5 arm raised partway (left) +const ARM_LEFT_UP: BodyPart = { + pixels: [ + [4, 3], + [3, 2], + [3, 2], + [3, 2], + [1, 1], + ], + offsetX: 9, + offsetY: 8, +}; + +// 2x5 arm raised partway (right) +const ARM_RIGHT_UP: BodyPart = { + pixels: [ + [3, 4], + [2, 3], + [2, 3], + [2, 3], + [1, 1], + ], + offsetX: 21, + offsetY: 8, +}; + +// 2x5 arm raised high (left, for celebrating) +const ARM_LEFT_RAISED_HIGH: BodyPart = { + pixels: [ + [4, 3], + [3, 2], + [3, 2], + [3, 2], + [1, 1], + ], + offsetX: 9, + offsetY: 5, +}; + +// 2x5 arm raised high (right, for celebrating) +const ARM_RIGHT_RAISED_HIGH: BodyPart = { + pixels: [ + [3, 4], + [2, 3], + [2, 3], + [2, 3], + [1, 1], + ], + offsetX: 21, + offsetY: 5, +}; + +// 2x5 arm raised high and splayed out (left, for V-shape celebrating) +const ARM_LEFT_SPLAYED: BodyPart = { + pixels: [ + [4, 3], + [3, 2], + [3, 2], + [3, 2], + [1, 1], + ], + offsetX: 7, + offsetY: 5, +}; + +// 2x5 arm raised high and splayed out (right, for V-shape celebrating) +const ARM_RIGHT_SPLAYED: BodyPart = { + pixels: [ + [3, 4], + [2, 3], + [2, 3], + [2, 3], + [1, 1], + ], + offsetX: 23, + offsetY: 5, +}; + +// 5x2 arm extended horizontally (left) +const ARM_LEFT_EXTENDED: BodyPart = { + pixels: [ + [4, 3, 3, 3, 1], + [1, 2, 2, 2, 1], + ], + offsetX: 6, + offsetY: 12, +}; + +// 5x2 arm extended horizontally (right) +const ARM_RIGHT_EXTENDED: BodyPart = { + pixels: [ + [1, 3, 3, 3, 4], + [1, 2, 2, 2, 1], + ], + offsetX: 21, + offsetY: 12, +}; + +// 3x4 legs standing straight (pair) +const LEG_STAND: BodyPart = { + pixels: [ + [0, 3, 0, 0, 0, 3, 0], + [0, 3, 0, 0, 0, 3, 0], + [0, 2, 0, 0, 0, 2, 0], + [1, 1, 1, 0, 1, 1, 1], + ], + offsetX: 12, + offsetY: 18, +}; + +// Legs with left leg forward, right leg back (walking mid-stride) +const LEG_FORWARD: BodyPart = { + pixels: [ + [0, 3, 0, 0, 0, 0, 3], + [3, 2, 0, 0, 0, 0, 2], + [1, 1, 0, 0, 0, 0, 2], + [0, 0, 0, 0, 0, 1, 1], + ], + offsetX: 11, + offsetY: 18, +}; + +// Legs with slight outward splay (for resting) +const LEG_RELAXED: BodyPart = { + pixels: [ + [3, 0, 0, 0, 0, 0, 3], + [3, 0, 0, 0, 0, 0, 3], + [2, 0, 0, 0, 0, 0, 2], + [1, 1, 0, 0, 0, 1, 1], + ], + offsetX: 12, + offsetY: 18, +}; + +// Arms pulled in close to body (for concerned) +const ARM_LEFT_PULLED_IN: BodyPart = { + pixels: [ + [4, 3], + [3, 2], + [3, 2], + [1, 1], + ], + offsetX: 10, + offsetY: 12, +}; + +const ARM_RIGHT_PULLED_IN: BodyPart = { + pixels: [ + [3, 4], + [2, 3], + [2, 3], + [1, 1], + ], + offsetX: 20, + offsetY: 12, +}; + +// Head shifted down 1px (for slouching/resting) +const HEAD_SLOUCH: BodyPart = { + ...HEAD, + offsetY: 5, +}; + +// ── 12 poses in sprite-sheet order ────────────────────────────────────────── + +/** Twelve animation poses for the subagent robot, ordered by sprite-sheet frame index. */ +export const SUBAGENT_POSES: Pose[] = [ + // Frame 0: Idle 1 — standing, arms down + [LEG_STAND, TORSO, ARM_LEFT_DOWN, ARM_RIGHT_DOWN, HEAD], + + // Frame 1: Idle 2 — arms shifted 1px (ping-pong variation) + [ + LEG_STAND, + TORSO, + { ...ARM_LEFT_DOWN, offsetX: ARM_LEFT_DOWN.offsetX - 1 }, + { ...ARM_RIGHT_DOWN, offsetX: ARM_RIGHT_DOWN.offsetX + 1 }, + HEAD, + ], + + // Frame 2: Walking — mid-stride with LEG_FORWARD + [LEG_FORWARD, TORSO, ARM_LEFT_UP, ARM_RIGHT_DOWN, HEAD], + + // Frame 3: Resting 1 — relaxed stance + [LEG_RELAXED, TORSO, ARM_LEFT_DOWN, ARM_RIGHT_DOWN, HEAD], + + // Frame 4: Working 1 — both arms raised + [LEG_STAND, TORSO, ARM_LEFT_UP, ARM_RIGHT_UP, HEAD], + + // Frame 5: Working 2 — arms extended horizontally + [LEG_STAND, TORSO, ARM_LEFT_EXTENDED, ARM_RIGHT_EXTENDED, HEAD], + + // Frame 6: Working 3 — arms lowering back (one up, one down) + [LEG_STAND, TORSO, ARM_LEFT_UP, ARM_RIGHT_DOWN, HEAD], + + // Frame 7: Resting 2 — slight slouch, head offset 1px down + [LEG_RELAXED, TORSO, ARM_LEFT_DOWN, ARM_RIGHT_DOWN, HEAD_SLOUCH], + + // Frame 8: Celebrating 1 — both arms raised high + [LEG_STAND, TORSO, ARM_LEFT_RAISED_HIGH, ARM_RIGHT_RAISED_HIGH, HEAD], + + // Frame 9: Celebrating 2 — arms in V-shape (splayed) + [LEG_STAND, TORSO, ARM_LEFT_SPLAYED, ARM_RIGHT_SPLAYED, HEAD], + + // Frame 10: Concerned — hunched, arms pulled in + [LEG_STAND, TORSO, ARM_LEFT_PULLED_IN, ARM_RIGHT_PULLED_IN, HEAD_SLOUCH], + + // Frame 11: Resting 3 — powered-down, arms at sides + [LEG_RELAXED, TORSO, ARM_LEFT_DOWN, ARM_RIGHT_DOWN, HEAD], +]; diff --git a/packages/factory/scripts/sprites/svg-renderer.ts b/packages/factory/scripts/sprites/svg-renderer.ts new file mode 100644 index 00000000..f6d6924c --- /dev/null +++ b/packages/factory/scripts/sprites/svg-renderer.ts @@ -0,0 +1,77 @@ +/** A 2D pixel grid of palette indices, where 0 is transparent. */ +export type PixelGrid = ReadonlyArray>; + +/** 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 all 12 poses into a 128x96 SVG sprite sheet with 4 columns and 3 rows. */ +export function renderSpriteSheet(poses: Pose[], palette: Palette): string { + 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 || 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"], } From a2076f61e412594cecc802dc6351c74c91c34066 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Sun, 8 Mar 2026 20:25:39 -0700 Subject: [PATCH 2/2] factory|tests: Add svg-renderer tests and input validation Add comprehensive test coverage for composePose and renderSpriteSheet, including painter's algorithm compositing, boundary clipping, transparency handling, frame positioning, and pose data integrity checks for both subagent and orchestrator pose arrays. Add a pose count guard to renderSpriteSheet that throws on non-12 input. Emit a console.warn for out-of-range palette indices instead of silently dropping pixels. Extend vitest config include pattern to discover scripts/ tests. Fix stale "Placeholder" label in the build script log message. --- .../scripts/generate-placeholder-pngs.ts | 2 +- .../sprites/__tests__/svg-renderer.test.ts | 313 ++++++++++++++++++ .../factory/scripts/sprites/svg-renderer.ts | 13 +- packages/factory/vitest.config.ts | 2 +- 4 files changed, 326 insertions(+), 4 deletions(-) create mode 100644 packages/factory/scripts/sprites/__tests__/svg-renderer.test.ts diff --git a/packages/factory/scripts/generate-placeholder-pngs.ts b/packages/factory/scripts/generate-placeholder-pngs.ts index 327769b1..08ef7f8e 100644 --- a/packages/factory/scripts/generate-placeholder-pngs.ts +++ b/packages/factory/scripts/generate-placeholder-pngs.ts @@ -25,4 +25,4 @@ const orchestratorSvg = renderSpriteSheet(ORCHESTRATOR_POSES, ORCHESTRATOR_PALET 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('`); } 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'], }, });