From 0684e76dc5c02d0917e936d97bfa56b0dc00f661 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sun, 18 Oct 2026 16:59:08 +0200 Subject: [PATCH] Let the agent draw pixel art and vector art (#429) Stage 5c of the MCP work. The agent could place sprites and edit graphs, but it could not draw: pixel art, palettes and vector art had no commands in its catalog. Two new areas bring 29 commands, so the catalog now holds 144. - pixelart (13): paint, layers (add, copy, remove, change, move, merge down), frames (add, also as a copy, remove, move), canvas size with an anchor, palette colors set and removed. - paint_pixels edits one layer on one frame. Its steps run in order: rows (a small picture as characters, a space keeps the pixel), pixels, line, rect, ellipse, fill, clear. An index the palette does not have, a pixel off the canvas and an unknown character are refused. - vectorart (16): shapes from rings or as rect and ellipse, outline, style (fill, opacity, gradient or image fill, stroke), move, scale, rotate and flip, copy, remove, combine, rename, order, move into groups, group, layers. - get_asset gives the palette of pixel art as colors. Up to 16384 pixels over all layers and frames each layer comes as rows, above that only the number of painted pixels. Palette and vector colors come as #rrggbb. - Merge down, canvas size and removing a color are now builders in pixelActions.ts. The panel and the agent share the same code. - Color and point helpers moved into catalog/shared.ts, and graphs.ts uses them too. Found while testing: - The ellipse tests only checked the four extreme points, which stay right even with wrong handle lengths. The test now checks that every point of the drawn curve lies on the ellipse. Tests: - agent-drawing (new, 9) and the other agent, pixel and relay tests: 133/133. - 7 mutations, all red in the matching test. - e2e 71 on melektaus-atx: 24/24. New check: the agent's pixel art opens in its tab, reads back as rows, and its cel is on the server after saving (status 200). - Live in a test project: an 8x8 sprite and a copied second frame read back as painted, a rotated square and a radial gradient disc with their corners where computed. - Gate on melektaus-atx: CHECK EXIT 0, 322 files, 4978 tests. Co-Authored-By: Claude Opus 5.5 --- src/editor/agent/agentTools.ts | 4 +- src/editor/agent/catalog/graphs.ts | 7 +- src/editor/agent/catalog/pixelart.ts | 479 +++++++++++++++++++++ src/editor/agent/catalog/shared.ts | 19 +- src/editor/agent/catalog/vectorart.ts | 593 ++++++++++++++++++++++++++ src/editor/agent/commandCatalog.ts | 12 + src/editor/agent/readTools.ts | 58 ++- src/editor/agent/tools.json | 2 +- src/editor/pixelart/pixelActions.ts | 75 ++-- tests/editor/agent-drawing.test.ts | 285 +++++++++++++ tools/e2e/71-mcp.mjs | 48 ++- 11 files changed, 1546 insertions(+), 36 deletions(-) create mode 100644 src/editor/agent/catalog/pixelart.ts create mode 100644 src/editor/agent/catalog/vectorart.ts create mode 100644 tests/editor/agent-drawing.test.ts diff --git a/src/editor/agent/agentTools.ts b/src/editor/agent/agentTools.ts index d771de21..97953dab 100644 --- a/src/editor/agent/agentTools.ts +++ b/src/editor/agent/agentTools.ts @@ -7,6 +7,7 @@ import type { AgentView } from './agentView'; import { AgentEdit, deletedAssets, type EditStep } from './editPlan'; import type { StepTarget } from './commandCatalog'; import { cameraOfWorld } from '../../viewport/activeCamera'; +import { pixelCels } from '../../viewport/PixelCels'; import { type InputStep, inputProblem, @@ -72,6 +73,7 @@ export interface ToolHost { } const DEFAULT_SCREENSHOT_WIDTH = 1280; +const readCel = (file: string) => pixelCels.read(file); /** Longer than this, an answer is cut: an agent reads it whole, every time. */ const MAX_ANSWER_CHARS = 60_000; @@ -133,7 +135,7 @@ async function run(name: string, args: Record, host: ToolHost): assets: listAssets(host.project(), args.type as string, args.query as string), }); case 'get_asset': - return answer(getAsset(host.project(), args.asset as string) ?? missing(args.asset)); + return answer(getAsset(host.project(), args.asset as string, readCel) ?? missing(args.asset)); case 'get_graph': return answer(getGraph(host.project(), args.asset as string) ?? missing(args.asset, ' with a graph')); case 'lint_graph': { diff --git a/src/editor/agent/catalog/graphs.ts b/src/editor/agent/catalog/graphs.ts index 9ea13c76..6d408be4 100644 --- a/src/editor/agent/catalog/graphs.ts +++ b/src/editor/agent/catalog/graphs.ts @@ -46,7 +46,6 @@ import type { PropertyBag, PropertyValue } from '../../../core/model/Properties' import { findGraphAsset, type Project } from '../../../core/model/Project'; import { DECLARED_PORT_KINDS, type DeclaredPortKind } from '../../../core/properties/kinds'; import { coerceValue } from '../../../core/properties/PropertySchema'; -import { parseHex } from '../../../ui/color'; import { groupBoxAround, noteBoxAbove } from '../../graph/annotationDrag'; import { arrangeCommand, layoutOf } from '../../graph/graphLayout'; import { type PaletteEntry, paletteEntries } from '../../graph/paletteEntries'; @@ -57,6 +56,7 @@ import { type CatalogCommand, flag, GRAPH, + hexColor, id, ids, listOf, @@ -604,10 +604,7 @@ function requireAnnotationOf(project: Project, assetId: string, annotationId: st } function colorOf(value: string): number | null { - if (value === '') return null; - const color = parseHex(value); - if (color === null) throw new CommandError(`"${value}" is no color – write "#rrggbb"`); - return color; + return value === '' ? null : hexColor(value); } /** Where a group or comment on these nodes goes, by the nodes as they stand now. */ diff --git a/src/editor/agent/catalog/pixelart.ts b/src/editor/agent/catalog/pixelart.ts new file mode 100644 index 00000000..10784457 --- /dev/null +++ b/src/editor/agent/catalog/pixelart.ts @@ -0,0 +1,479 @@ +import { type Command, CommandError } from '../../../core/commands/Command'; +import { EditPixelArtCommand, SetPaletteColorsCommand } from '../../../core/commands/pixelArtCommands'; +import { newId } from '../../../core/ids'; +import type { PaletteAsset, PixelArtAsset } from '../../../core/model/Asset'; +import { celOf, PALETTE_MAX, paletteColors } from '../../../core/model/pixelart'; +import { type CelPixels, emptyCel, PIXEL_CANVAS_MAX } from '../../../core/model/pixelCel'; +import { + BRUSH_MAX, + BRUSH_MIN, + cloneCel, + ellipsePoints, + floodFill, + linePoints, + type PixelPoint, + rectPoints, + type ResizeAnchor, + stampAll, +} from '../../../core/model/pixelDraw'; +import { + type PixelArtState, + withCels, + withFrameAdded, + withFrameMoved, + withFrameRemoved, + withLayerAdded, + withLayerChanged, + withLayerDuplicated, + withLayerMoved, + withLayerRemoved, +} from '../../../core/model/pixelEdit'; +import { findAssetOf, type Project } from '../../../core/model/Project'; +import { pixelCels } from '../../../viewport/PixelCels'; +import { + mergeLayerDownCommand, + removePaletteColorCommand, + resizeCanvasCommand, + STILL_LOADING, +} from '../../pixelart/pixelActions'; +import type { ArgSchema } from '../argCheck'; +import { + type Args, + type Built, + type CatalogCommand, + color, + flag, + hexColor, + id, + number, + numberOf, + object, + oneOf, + text, + theAsset, + theTab, + whole, +} from './shared'; + +const PIXEL_ART = id('Id of the pixel art asset.'); +const PALETTE = id('Id of the palette asset.'); +const LAYER = id('Id of the layer, as get_asset lists it under layers.'); +const FRAME = id('Id of the frame, as get_asset lists it under frames.'); +const INDEX = whole('Its new place, from 0 (the bottom layer, the first frame).', 0); +const AT = (description: string): ArgSchema => ({ + ...object({ x: whole('Column.', 0), y: whole('Row.', 0) }, ['x', 'y']), + description, +}); + +/** + * How the rows of a picture spell a palette index, one character a pixel: "." is 0 (transparent), + * "1"–"9" are 1–9, "a"–"z" 10–35 and "A"–"Z" 36–61. A space in rows to paint leaves the pixel alone. + */ +const ROW_ALPHABET = '.123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; +export const MAX_ROW_INDEX = ROW_ALPHABET.length - 1; + +/** The cel as rows of characters, or null when it uses an index past what one character spells. */ +export function celRows(cel: CelPixels): string[] | null { + const rows: string[] = []; + for (let y = 0; y < cel.height; y++) { + let row = ''; + for (let x = 0; x < cel.width; x++) { + const index = cel.data[y * cel.width + x]!; + if (index > MAX_ROW_INDEX) return null; + row += ROW_ALPHABET[index]; + } + rows.push(row); + } + return rows; +} + +const DRAWS = ['rows', 'pixels', 'line', 'rect', 'ellipse', 'fill', 'clear'] as const; +type Draw = (typeof DRAWS)[number]; + +interface DrawStep { + draw: Draw; + color?: number; + rows?: string[]; + at?: PixelPoint; + points?: PixelPoint[]; + from?: PixelPoint; + to?: PixelPoint; + filled?: boolean; + size?: number; + contiguous?: boolean; +} + +const DRAW_STEP = object( + { + draw: oneOf( + DRAWS, + 'rows: a picture from at (top-left), one string a row. pixels: the points. line, rect, ellipse: ' + + 'from one corner to the other, both included. fill: the area of one color around at. clear: the whole cel.', + ), + color: whole('Palette index to paint with; 0 erases. Not for rows and clear.', 0), + rows: { + type: 'array', + items: { type: 'string' }, + description: + 'For rows: one character a pixel. "." is 0, "1"-"9" are 1-9, "a"-"z" 10-35, "A"-"Z" 36-61; ' + + 'a space leaves the pixel as it is.', + }, + at: AT('For rows its top-left corner (0,0 when left out), for fill the pixel to start from.'), + points: { type: 'array', items: AT('A pixel.'), description: 'For pixels.' }, + from: AT('For line, rect and ellipse: one corner.'), + to: AT('The other corner.'), + filled: flag('For rect and ellipse: filled rather than an outline (default false).'), + size: whole( + `Brush size for line, rect and ellipse outlines, ${BRUSH_MIN} to ${BRUSH_MAX} (default 1).`, + 1, + ), + contiguous: flag('For fill: only the joined area (default true), or every pixel of that color.'), + }, + ['draw'], +); + +/** Pixel art: painting it, its layers, frames and canvas, and the colors of palettes. */ +export const PIXEL_ART_COMMANDS: readonly CatalogCommand[] = [ + { + name: 'paint_pixels', + description: + 'Paints one layer on one frame of a pixel art asset, the draw steps in order. Colors are indices into ' + + 'its palette (get_asset lists them under colors, the first is index 1); 0 is transparent. 0,0 is the ' + + 'top-left pixel. Rows are the easiest way to set down a small picture.', + args: object( + { + asset: PIXEL_ART, + layer: { ...LAYER, description: 'Id of the layer; the first (bottom) one when left out.' }, + frame: { ...FRAME, description: 'Id of the frame; the first one when left out.' }, + draws: { type: 'array', minItems: 1, items: DRAW_STEP, description: 'What to paint, in order.' }, + }, + ['asset', 'draws'], + ), + build: (args, { project }) => paintCommand(project, args), + shows: theTab, + }, + { + name: 'add_pixel_layer', + description: 'Adds an empty layer right above another one, or on top.', + args: object({ asset: PIXEL_ART, above: { ...LAYER, description: 'The layer it goes above.' } }, [ + 'asset', + ]), + build: (args) => { + const layer = newId(); + const above = (args.above as string | undefined) ?? ''; + return made( + edit(args, 'Add layer', (s) => withLayerAdded(s, layer, above)), + layer, + ); + }, + shows: theTab, + }, + { + name: 'duplicate_pixel_layer', + description: 'Copies a layer with its pixels on every frame; the copy comes right above it.', + args: object({ asset: PIXEL_ART, layer: LAYER }, ['asset', 'layer']), + build: (args) => { + const layer = newId(); + return made( + edit(args, 'Duplicate layer', (s) => withLayerDuplicated(s, layer, text(args, 'layer'))), + layer, + ); + }, + shows: theTab, + }, + { + name: 'remove_pixel_layer', + description: 'Removes a layer with its pixels on every frame; the last layer stays.', + args: object({ asset: PIXEL_ART, layer: LAYER }, ['asset', 'layer']), + build: (args) => ({ + command: edit(args, 'Delete layer', (s) => withLayerRemoved(s, text(args, 'layer'))), + }), + shows: theTab, + }, + { + name: 'set_pixel_layer', + description: + 'Changes a layer: its name, whether it is hidden, its opacity (0 to 1). Give only what changes.', + args: object( + { + asset: PIXEL_ART, + layer: LAYER, + name: id('Its name.'), + hidden: flag('Hidden.'), + opacity: number('From 0 to 1.'), + }, + ['asset', 'layer'], + ), + build: (args) => { + const change = { + ...(typeof args.name === 'string' ? { name: args.name } : {}), + ...(typeof args.hidden === 'boolean' ? { hidden: args.hidden } : {}), + ...(typeof args.opacity === 'number' ? { opacity: args.opacity } : {}), + }; + if (Object.keys(change).length === 0) + throw new CommandError('Name what to change: name, hidden or opacity'); + return { command: edit(args, 'Change layer', (s) => withLayerChanged(s, text(args, 'layer'), change)) }; + }, + shows: theTab, + }, + { + name: 'move_pixel_layer', + description: 'Moves a layer to another place in the drawing order; the last one is drawn on top.', + args: object({ asset: PIXEL_ART, layer: LAYER, to: INDEX }, ['asset', 'layer', 'to']), + build: (args) => ({ + command: edit(args, 'Reorder layers', (s) => + withLayerMoved(s, text(args, 'layer'), numberOf(args, 'to')), + ), + }), + shows: theTab, + }, + { + name: 'merge_pixel_layer_down', + description: + 'Lays a layer onto the one below it on every frame and removes it. Its opacity is not kept: indexed colors ' + + 'have no in-between color.', + args: object({ asset: PIXEL_ART, layer: LAYER }, ['asset', 'layer']), + build: (args, { project }) => ({ + command: refusedIfText(mergeLayerDownCommand(requirePixelArt(project, args), text(args, 'layer'))), + }), + shows: theTab, + }, + { + name: 'add_pixel_frame', + description: + 'Adds a frame after another one, or at the end: empty, or with copy a copy of the frame it follows. All ' + + 'frames share the layers and the canvas size; a sprite shows one frame, an animation steps through them.', + args: object( + { + asset: PIXEL_ART, + after: { ...FRAME, description: 'The frame it follows.' }, + copy: flag('Copy the pixels of the frame it follows (needs after).'), + }, + ['asset'], + ), + build: (args) => { + const frame = newId(); + const after = (args.after as string | undefined) ?? ''; + if (args.copy === true && !after) throw new CommandError('Name the frame to copy in after'); + const label = args.copy === true ? 'Duplicate frame' : 'Add frame'; + return made( + edit(args, label, (s) => withFrameAdded(s, frame, after, args.copy === true ? after : '')), + frame, + ); + }, + shows: theTab, + }, + { + name: 'remove_pixel_frame', + description: 'Removes a frame; the last one stays.', + args: object({ asset: PIXEL_ART, frame: FRAME }, ['asset', 'frame']), + build: (args) => ({ + command: edit(args, 'Delete frame', (s) => withFrameRemoved(s, text(args, 'frame'))), + }), + shows: theTab, + }, + { + name: 'move_pixel_frame', + description: 'Moves a frame to another place in the sequence.', + args: object({ asset: PIXEL_ART, frame: FRAME, to: INDEX }, ['asset', 'frame', 'to']), + build: (args) => ({ + command: edit(args, 'Reorder frames', (s) => + withFrameMoved(s, text(args, 'frame'), numberOf(args, 'to')), + ), + }), + shows: theTab, + }, + { + name: 'resize_pixel_canvas', + description: + `Gives the canvas another size, 1 to ${PIXEL_CANVAS_MAX} pixels a side, on every layer and frame. The ` + + 'anchor says where the old picture stays; what sticks out is cut.', + args: object( + { + asset: PIXEL_ART, + width: whole('Width in pixels.', 1), + height: whole('Height in pixels.', 1), + anchorX: oneOf(['left', 'center', 'right'], 'Where the old picture stays across (default left).'), + anchorY: oneOf(['top', 'center', 'bottom'], 'And down (default top).'), + }, + ['asset', 'width', 'height'], + ), + build: (args, { project }) => { + const asset = requirePixelArt(project, args); + const width = numberOf(args, 'width'); + const height = numberOf(args, 'height'); + if (width > PIXEL_CANVAS_MAX || height > PIXEL_CANVAS_MAX) + throw new CommandError(`A side is 1 to ${PIXEL_CANVAS_MAX} pixels`); + const anchor: ResizeAnchor = { + x: ANCHORS[(args.anchorX as string | undefined) ?? 'left']!, + y: ANCHORS[(args.anchorY as string | undefined) ?? 'top']!, + }; + const command = resizeCanvasCommand(asset, width, height, anchor); + if (command === null) throw new CommandError(`${asset.name} is ${width}x${height} already`); + return { command: refusedIfText(command) }; + }, + shows: theTab, + }, + { + name: 'set_palette_colors', + description: + `Sets all colors of a palette at once, index 1 first; at most ${PALETTE_MAX}. Every pixel art painted ` + + 'with it changes color with it. To take a color out with its pixels, use remove_palette_color.', + args: object( + { + asset: PALETTE, + colors: { type: 'array', items: color('A color.'), description: 'The colors, index 1 first.' }, + }, + ['asset', 'colors'], + ), + build: (args, { project }) => { + const palette = requirePalette(project, args); + const colors = (args.colors as string[]).map(hexColor); + return { command: new SetPaletteColorsCommand(palette.id, 'Palette colors', colors) }; + }, + shows: theAsset, + }, + { + name: 'remove_palette_color', + description: + 'Takes one color out of a palette. Its pixels turn transparent in every pixel art painted with it, and ' + + 'the colors after it move down one index, so the other pixels keep their color.', + args: object({ asset: PALETTE, index: whole('Its index, from 1.', 1) }, ['asset', 'index']), + build: (args, { project }) => { + const palette = requirePalette(project, args); + const command = removePaletteColorCommand(project, palette, numberOf(args, 'index')); + if (command === null) + throw new CommandError( + `${palette.name} has ${palette.colors.length} colors, no index ${numberOf(args, 'index')}`, + ); + return { command: refusedIfText(command) }; + }, + shows: theAsset, + }, +]; + +const ANCHORS: Record = { left: 0, top: 0, center: 0.5, right: 1, bottom: 1 }; + +function requirePixelArt(project: Project, args: Args): PixelArtAsset { + const asset = findAssetOf(project, text(args, 'asset'), 'pixelart'); + if (!asset) throw new CommandError(`Pixel art ${text(args, 'asset')} not found`); + return asset; +} + +function requirePalette(project: Project, args: Args): PaletteAsset { + const asset = findAssetOf(project, text(args, 'asset'), 'palette'); + if (!asset) throw new CommandError(`Palette ${text(args, 'asset')} not found`); + return asset; +} + +function edit(args: Args, label: string, run: (state: PixelArtState) => PixelArtState): Command { + return new EditPixelArtCommand(text(args, 'asset'), label, run); +} + +function made(command: Command, id: string): Built { + return { command, made: { id } }; +} + +function refusedIfText(command: Command | string): Command { + if (typeof command === 'string') throw new CommandError(command); + return command; +} + +/** One cel painted as the steps say, written into the asset as one undo step. */ +function paintCommand(project: Project, args: Args): Built { + const asset = requirePixelArt(project, args); + const layerId = (args.layer as string | undefined) ?? asset.layers[0]!.id; + const frameId = (args.frame as string | undefined) ?? asset.frames[0]!.id; + if (!asset.layers.some((layer) => layer.id === layerId)) + throw new CommandError(`${asset.name} has no layer ${layerId}`); + const frame = asset.frames.find((f) => f.id === frameId); + if (!frame) throw new CommandError(`${asset.name} has no frame ${frameId}`); + const stored = pixelCels.read(celOf(frame, layerId)); + if (stored === undefined) throw new CommandError(STILL_LOADING); + const cel = stored ? cloneCel(stored) : emptyCel(asset.width, asset.height); + const colors = paletteColors(project, asset).length; + (args.draws as DrawStep[]).forEach((step, n) => { + try { + paintStep(cel, step, colors); + } catch (error) { + if (error instanceof CommandError) throw new CommandError(`draws[${n}]: ${error.message}`); + throw error; + } + }); + const file = pixelCels.put(cel); + return { + command: new EditPixelArtCommand(asset.id, 'Paint pixels', (s) => + withCels(s, [{ frameId, layerId, file }]), + ), + }; +} + +function paintStep(cel: CelPixels, step: DrawStep, colors: number): void { + const inside = (point: PixelPoint | undefined, what: string): PixelPoint => { + if (!point) throw new CommandError(`${step.draw} needs ${what}`); + if (point.x >= cel.width || point.y >= cel.height) + throw new CommandError(`${point.x},${point.y} lies outside the ${cel.width}x${cel.height} canvas`); + return point; + }; + const index = (): number => { + if (step.color === undefined) throw new CommandError(`${step.draw} needs a color`); + if (step.color > colors) + throw new CommandError(`The palette has ${colors} colors, so ${step.color} is no index (0 erases)`); + return step.color; + }; + const size = step.size ?? 1; + if (size > BRUSH_MAX) throw new CommandError(`A brush is ${BRUSH_MIN} to ${BRUSH_MAX} pixels`); + switch (step.draw) { + case 'rows': + return paintRows(cel, step, colors); + case 'pixels': { + if (!step.points?.length) throw new CommandError('pixels needs points'); + const color = index(); + stampAll( + cel, + step.points.map((point) => inside(point, 'points')), + 1, + color, + ); + return; + } + case 'line': + return stampAll(cel, linePoints(inside(step.from, 'from'), inside(step.to, 'to')), size, index()); + case 'rect': + case 'ellipse': { + const from = inside(step.from, 'from'); + const to = inside(step.to, 'to'); + const filled = step.filled === true; + const points = step.draw === 'rect' ? rectPoints(from, to, filled) : ellipsePoints(from, to, filled); + return stampAll(cel, points, filled ? 1 : size, index()); + } + case 'fill': + return floodFill(cel, inside(step.at, 'at'), index(), step.contiguous !== false); + case 'clear': + cel.data.fill(0); + return; + } +} + +function paintRows(cel: CelPixels, step: DrawStep, colors: number): void { + const rows = step.rows; + if (!rows?.length) throw new CommandError('rows needs rows'); + const x0 = step.at?.x ?? 0; + const y0 = step.at?.y ?? 0; + const widest = Math.max(...rows.map((row) => row.length)); + if (x0 + widest > cel.width || y0 + rows.length > cel.height) + throw new CommandError( + `${widest}x${rows.length} rows at ${x0},${y0} reach past the ${cel.width}x${cel.height} canvas`, + ); + rows.forEach((row, dy) => { + [...row].forEach((char, dx) => { + if (char === ' ') return; + const index = ROW_ALPHABET.indexOf(char); + if (index < 0) + throw new CommandError(`"${char}" in row ${dy} is no index – use . 1-9 a-z A-Z or a space`); + if (index > colors) + throw new CommandError(`"${char}" is index ${index}, the palette has ${colors} colors`); + cel.data[(y0 + dy) * cel.width + x0 + dx] = index; + }); + }); +} diff --git a/src/editor/agent/catalog/shared.ts b/src/editor/agent/catalog/shared.ts index 81a00fef..d3899f31 100644 --- a/src/editor/agent/catalog/shared.ts +++ b/src/editor/agent/catalog/shared.ts @@ -1,6 +1,7 @@ -import type { Command } from '../../../core/commands/Command'; +import { type Command, CommandError } from '../../../core/commands/Command'; import type { AnnotationKind } from '../../../core/graph'; import type { Project } from '../../../core/model/Project'; +import { parseHex } from '../../../ui/color'; import type { MeasureNode } from '../../graph/graphLayout'; import type { ArgSchema } from '../argCheck'; @@ -83,6 +84,15 @@ export const object = (properties: Record, required: string[] additionalProperties: false, }); +export const color = (description: string): ArgSchema => ({ + type: 'string', + description: `${description} As "#rrggbb".`, +}); +export const xy = (description: string): ArgSchema => ({ + ...object({ x: number('x'), y: number('y') }, ['x', 'y']), + description, +}); + export const ASSET = id('Id of the asset.'); export const HOLDER = id('Id of the scene or container the object is in.'); export const OBJECT = id('Id of the placed object.'); @@ -99,6 +109,13 @@ export const text = (args: Args, key: string): string => args[key] as string; export const numberOf = (args: Args, key: string): number => args[key] as number; export const listOf = (args: Args, key: string): string[] => args[key] as string[]; +/** A color the agent wrote as "#rrggbb", refused in words when it is none. */ +export function hexColor(value: string): number { + const parsed = parseHex(value); + if (parsed === null) throw new CommandError(`"${value}" is no color – write "#rrggbb"`); + return parsed; +} + export const theAsset = (args: Args): StepTarget => ({ kind: 'asset', asset: text(args, 'asset') }); export const theTab = (args: Args): StepTarget => ({ kind: 'asset', asset: text(args, 'asset'), open: true }); export const madeAsset = (_args: Args, made: Record): StepTarget => ({ diff --git a/src/editor/agent/catalog/vectorart.ts b/src/editor/agent/catalog/vectorart.ts new file mode 100644 index 00000000..3ed99ee1 --- /dev/null +++ b/src/editor/agent/catalog/vectorart.ts @@ -0,0 +1,593 @@ +import { type Command, CommandError } from '../../../core/commands/Command'; +import { CompoundCommand } from '../../../core/commands/CompoundCommand'; +import { requireVectorArt, requireVectorShape } from '../../../core/commands/require'; +import { + AddVectorLayerCommand, + AddVectorShapeCommand, + CombineVectorShapesCommand, + DuplicateVectorShapeCommand, + GroupVectorShapesCommand, + MoveVectorLayerCommand, + MoveVectorShapesCommand, + OrderVectorShapeCommand, + RemoveVectorLayerCommand, + RemoveVectorShapesCommand, + RenameVectorLayerCommand, + RenameVectorShapeCommand, + SetVectorLayerHiddenCommand, + SetVectorShapeRingsCommand, + SetVectorShapeStyleCommand, + type ShapeStyle, + transformShapesCommand, + UngroupVectorShapesCommand, +} from '../../../core/commands/vectorArtCommands'; +import type { + GradientStop, + VectorArtAsset, + VectorGradient, + VectorPaint, + VectorPoint, + VectorRing, + VectorShape, +} from '../../../core/model/Asset'; +import { IDENTITY, type Mat, multiply, translation } from '../../../core/model/matrix'; +import type { Project } from '../../../core/model/Project'; +import { SHAPE_FILL_DEFAULT } from '../../../core/model/vectorart'; +import type { ShapeBoolean } from '../../../core/model/vectorBoolean'; +import { textureFillRef } from '../../../core/model/vectorPaint'; +import { + findVectorItem, + isVectorGroup, + placeOf, + type ShapeOrderMove, + shapesIn, + type TreeDrop, +} from '../../../core/model/vectorTree'; +import { + extentCentre, + frameFlipped, + outlineExtent, + rotationAbout, + scaleAbout, +} from '../../../core/model/vectorTransform'; +import type { ArgSchema } from '../argCheck'; +import { + type Args, + type CatalogCommand, + color, + flag, + hexColor, + id, + ids, + listOf, + number, + numberOf, + object, + oneOf, + text, + theTab, + whole, + xy, +} from './shared'; + +const VECTOR_ART = id('Id of the vector art asset.'); +const SHAPE = id('Id of the shape (or group), as get_asset lists it in the layers.'); +const SHAPES = ids('Ids of the shapes or groups.'); +const LAYER = id('Id of the layer, as get_asset lists it under layers.'); + +const POINT: ArgSchema = object( + { + x: number('x in canvas pixels, from the left.'), + y: number('y in canvas pixels, from the top.'), + in: xy('Bezier handle towards the point before, as an offset from the point; leave out for a corner.'), + out: xy('Bezier handle towards the point after, as an offset.'), + mode: oneOf(['corner', 'smooth', 'symmetric'], 'How the handles move together when dragged.'), + }, + ['x', 'y'], +); +const RING: ArgSchema = object( + { + points: { type: 'array', minItems: 2, items: POINT, description: 'The points in order.' }, + closed: flag('A closed outline is filled; an open one is only stroked (default true).'), + }, + ['points'], +); +const BOX = (description: string): ArgSchema => ({ + ...object( + { + x: number('Left edge.'), + y: number('Top edge.'), + w: number('Width.'), + h: number('Height.'), + }, + ['x', 'y', 'w', 'h'], + ), + description, +}); +/** The outline of a shape, as rings or a figure; exactly one of them. */ +const OUTLINE: Record = { + rings: { + type: 'array', + minItems: 1, + items: RING, + description: 'Outlines; rings are filled even-odd, so a ring inside another cuts a hole.', + }, + rect: BOX('A rectangle instead of rings.'), + ellipse: BOX('The ellipse that fits this box instead of rings.'), +}; + +const STOP = object( + { + offset: number('0 at from, 1 at to.'), + color: color('Its color.'), + alpha: number('0 to 1 (default 1).'), + }, + ['offset', 'color'], +); +const PAINT: ArgSchema = object( + { + type: oneOf( + ['solid', 'linear', 'radial', 'texture'], + 'solid goes back to the plain fill color. linear runs from from to to; radial has its centre on from ' + + 'and reaches its last color at the distance of to; texture repeats a picture, one tile from from to to.', + ), + from: xy('In canvas pixels.'), + to: xy('In canvas pixels.'), + stops: { type: 'array', minItems: 2, items: STOP, description: 'For linear and radial: the colors.' }, + texture: id('For texture: id of a texture or one of its frames.'), + }, + ['type'], +); +const STROKE: ArgSchema = object( + { + color: color('Its color (default: the one it has, else black).'), + width: number('In canvas pixels; 0 takes the outline away.'), + paint: { + ...PAINT, + description: 'A linear or radial gradient along the outline; type solid takes it away.', + }, + }, + ['width'], +); +const STYLE: Record = { + fill: color('Fill color.'), + opacity: number('0 to 1.'), + paint: { ...PAINT, description: 'Fill with a gradient or a picture instead of the fill color.' }, + stroke: { ...STROKE, description: 'The outline drawn along the rings.' }, +}; + +/** Vector art: shapes, their outlines and paints, groups and layers. */ +export const VECTOR_ART_COMMANDS: readonly CatalogCommand[] = [ + { + name: 'add_vector_shape', + description: + 'Adds a shape on top of a layer of a vector art asset, from rings or as a rect or ellipse. Coordinates ' + + 'are canvas pixels from the top-left corner; the canvas size is a property. The style can be given ' + + 'right away, as set_vector_shape_style takes it.', + args: object( + { + asset: VECTOR_ART, + layer: { ...LAYER, description: 'Id of the layer; the top one when left out.' }, + name: id('Its name (default "Shape n").'), + ...OUTLINE, + ...STYLE, + }, + ['asset'], + ), + build: (args, { project }) => { + const asset = requireVectorArt(project, text(args, 'asset')); + const fill = typeof args.fill === 'string' ? hexColor(args.fill) : SHAPE_FILL_DEFAULT; + const add = new AddVectorShapeCommand( + asset.id, + outlineOf(args), + fill, + (args.layer as string | undefined) ?? '', + ); + const steps: Command[] = [add]; + if (typeof args.name === 'string') + steps.push(new RenameVectorShapeCommand(asset.id, add.shapeId, args.name)); + const style = styleOf(project, args, null); + if (style) steps.push(new SetVectorShapeStyleCommand(asset.id, add.shapeId, style)); + return { command: new CompoundCommand('Add shape', steps), made: { id: add.shapeId } }; + }, + shows: theTab, + }, + { + name: 'set_vector_shape_outline', + description: 'Writes the outline of a shape anew: rings, a rect or an ellipse. Its style stays.', + args: object({ asset: VECTOR_ART, shape: SHAPE, ...OUTLINE }, ['asset', 'shape']), + build: (args, { project }) => { + requireVectorShape(project, text(args, 'asset'), text(args, 'shape')); + return { + command: new SetVectorShapeRingsCommand(text(args, 'asset'), text(args, 'shape'), outlineOf(args)), + }; + }, + shows: theTab, + }, + { + name: 'set_vector_shape_style', + description: + 'Changes how a shape is painted: fill color, opacity, a gradient or picture fill, the outline. Give only ' + + 'what changes.', + args: object({ asset: VECTOR_ART, shape: SHAPE, ...STYLE }, ['asset', 'shape']), + build: (args, { project }) => { + const shape = requireVectorShape(project, text(args, 'asset'), text(args, 'shape')); + const style = styleOf(project, args, shape); + if (!style) throw new CommandError('Name what to change: fill, opacity, paint or stroke'); + return { command: new SetVectorShapeStyleCommand(text(args, 'asset'), shape.id, style) }; + }, + shows: theTab, + }, + { + name: 'transform_vector_shapes', + description: + 'Moves, scales, turns or mirrors shapes and groups with their paints, as one step. Scaling, turning and ' + + 'mirroring go round the middle of all of them unless about is given; the move comes last.', + args: object( + { + asset: VECTOR_ART, + shapes: SHAPES, + move: xy('How far, in canvas pixels.'), + scaleX: number('Factor across; negative mirrors.'), + scaleY: number('Factor down.'), + rotate: number('Degrees, clockwise.'), + flip: oneOf(['x', 'y'], 'x swaps left and right, y top and bottom.'), + about: xy('The point that stays where it is.'), + }, + ['asset', 'shapes'], + ), + build: (args, { project }) => { + const asset = requireVectorArt(project, text(args, 'asset')); + const shapes = shapesOf(asset, listOf(args, 'shapes')); + const extent = outlineExtent(shapes); + const about = (args.about as { x: number; y: number } | undefined) ?? (extent && extentCentre(extent)); + let m: Mat = IDENTITY; + const then = (next: Mat) => (m = multiply(next, m)); + if (typeof args.flip === 'string') { + if (!extent) throw new CommandError('The shapes have no size to mirror'); + then(frameFlipped(extent, args.flip as 'x' | 'y')); + } + if (typeof args.scaleX === 'number' || typeof args.scaleY === 'number') { + if (!about) throw new CommandError('The shapes have no size to scale – give about'); + then( + scaleAbout( + about, + (args.scaleX as number | undefined) ?? 1, + (args.scaleY as number | undefined) ?? 1, + ), + ); + } + if (typeof args.rotate === 'number') { + if (!about) throw new CommandError('The shapes have no size to turn – give about'); + then(rotationAbout(about, args.rotate)); + } + const move = args.move as { x: number; y: number } | undefined; + if (move) then(translation(move.x, move.y)); + const command = transformShapesCommand( + asset, + shapes.map((shape) => shape.id), + m, + 'Transform shapes', + ); + if (!command) throw new CommandError('Nothing moves – give move, scaleX, scaleY, rotate or flip'); + return { command }; + }, + shows: theTab, + }, + { + name: 'duplicate_vector_shape', + description: 'Copies a shape or group right above it, on the same spot, with its style.', + args: object({ asset: VECTOR_ART, shape: SHAPE }, ['asset', 'shape']), + build: (args) => { + const command = new DuplicateVectorShapeCommand(text(args, 'asset'), text(args, 'shape')); + return { command, made: { id: command.shapeId } }; + }, + shows: theTab, + }, + { + name: 'remove_vector_shapes', + description: 'Removes shapes, and groups with all that is in them.', + args: object({ asset: VECTOR_ART, shapes: SHAPES }, ['asset', 'shapes']), + build: (args, { project }) => { + const asset = requireVectorArt(project, text(args, 'asset')); + const wanted = listOf(args, 'shapes'); + requireItems(asset, wanted); + return { command: new RemoveVectorShapesCommand(asset.id, wanted) }; + }, + shows: theTab, + }, + { + name: 'combine_vector_shapes', + description: + 'Combines shapes into one: union, subtract (the ones above cut out of the lowest), intersect or exclude. ' + + 'The result keeps the place, name and style of the lowest; the others go.', + args: object( + { + asset: VECTOR_ART, + shapes: { ...SHAPES, minItems: 2 }, + op: oneOf(['union', 'subtract', 'intersect', 'exclude'], 'How they combine.'), + }, + ['asset', 'shapes', 'op'], + ), + build: (args) => { + const command = new CombineVectorShapesCommand( + text(args, 'asset'), + listOf(args, 'shapes'), + text(args, 'op') as ShapeBoolean, + ); + return { command, made: () => ({ id: command.shapeId }) }; + }, + shows: theTab, + }, + { + name: 'rename_vector_shape', + description: 'Renames a shape or group.', + args: object({ asset: VECTOR_ART, shape: SHAPE, name: id('The new name.') }, ['asset', 'shape', 'name']), + build: (args) => ({ + command: new RenameVectorShapeCommand(text(args, 'asset'), text(args, 'shape'), text(args, 'name')), + }), + shows: theTab, + }, + { + name: 'order_vector_shape', + description: + 'Moves a shape or group one step (forward, backward) or all the way (front, back) among the items of its ' + + 'own list; the last one is drawn on top.', + args: object( + { asset: VECTOR_ART, shape: SHAPE, move: oneOf(['forward', 'front', 'backward', 'back'], 'Where to.') }, + ['asset', 'shape', 'move'], + ), + build: (args) => ({ + command: new OrderVectorShapeCommand( + text(args, 'asset'), + text(args, 'shape'), + text(args, 'move') as ShapeOrderMove, + ), + }), + shows: theTab, + }, + { + name: 'move_vector_shapes', + description: + 'Moves shapes and groups to another place in the tree: onto another layer, into a group, or right over ' + + 'another item. They land on top of the layer or group unless over is given.', + args: object( + { + asset: VECTOR_ART, + shapes: SHAPES, + layer: { ...LAYER, description: 'The layer they go to; the one of into or over when left out.' }, + into: id('A group they go into.'), + over: id('An item they are drawn right over.'), + }, + ['asset', 'shapes'], + ), + build: (args, { project }) => { + const asset = requireVectorArt(project, text(args, 'asset')); + const moved = listOf(args, 'shapes'); + requireItems(asset, moved); + const { layer, drop } = dropOf(asset, args, moved); + return { command: new MoveVectorShapesCommand(asset.id, moved, layer, drop) }; + }, + shows: theTab, + }, + { + name: 'group_vector_shapes', + description: 'Puts shapes and groups of one layer into a new group, where the topmost of them stood.', + args: object({ asset: VECTOR_ART, shapes: SHAPES }, ['asset', 'shapes']), + build: (args) => { + const command = new GroupVectorShapesCommand(text(args, 'asset'), listOf(args, 'shapes')); + return { command, made: { id: command.groupId } }; + }, + shows: theTab, + }, + { + name: 'ungroup_vector_shapes', + description: 'Takes groups away; what was in them stays where the group stood.', + args: object({ asset: VECTOR_ART, groups: ids('Ids of the groups.') }, ['asset', 'groups']), + build: (args) => ({ + command: new UngroupVectorShapesCommand(text(args, 'asset'), listOf(args, 'groups')), + }), + shows: theTab, + }, + { + name: 'add_vector_layer', + description: 'Adds an empty layer right over another one, or on top of all.', + args: object({ asset: VECTOR_ART, over: { ...LAYER, description: 'The layer it goes over.' } }, [ + 'asset', + ]), + build: (args) => { + const command = new AddVectorLayerCommand(text(args, 'asset'), (args.over as string | undefined) ?? ''); + return { command, made: { id: command.layerId } }; + }, + shows: theTab, + }, + { + name: 'remove_vector_layer', + description: 'Removes a layer with all that is drawn on it; the last layer stays.', + args: object({ asset: VECTOR_ART, layer: LAYER }, ['asset', 'layer']), + build: (args) => ({ command: new RemoveVectorLayerCommand(text(args, 'asset'), text(args, 'layer')) }), + shows: theTab, + }, + { + name: 'move_vector_layer', + description: 'Moves a layer to another place in the stack; the last one is drawn on top.', + args: object({ asset: VECTOR_ART, layer: LAYER, to: whole('Its new place, from 0 (the bottom).', 0) }, [ + 'asset', + 'layer', + 'to', + ]), + build: (args, { project }) => { + const asset = requireVectorArt(project, text(args, 'asset')); + const from = asset.layers.findIndex((layer) => layer.id === text(args, 'layer')); + if (from < 0) throw new CommandError(`${asset.name} has no layer ${text(args, 'layer')}`); + return { command: new MoveVectorLayerCommand(asset.id, from, numberOf(args, 'to')) }; + }, + shows: theTab, + }, + { + name: 'set_vector_layer', + description: 'Renames a layer or hides it. Give only what changes.', + args: object({ asset: VECTOR_ART, layer: LAYER, name: id('Its name.'), hidden: flag('Hidden.') }, [ + 'asset', + 'layer', + ]), + build: (args) => { + const asset = text(args, 'asset'); + const layer = text(args, 'layer'); + const steps: Command[] = []; + if (typeof args.name === 'string') steps.push(new RenameVectorLayerCommand(asset, layer, args.name)); + if (typeof args.hidden === 'boolean') + steps.push(new SetVectorLayerHiddenCommand(asset, layer, args.hidden)); + if (steps.length === 0) throw new CommandError('Name what to change: name or hidden'); + return { command: new CompoundCommand('Change layer', steps) }; + }, + shows: theTab, + }, +]; + +/** Bezier handles of a quarter ellipse: this share of the radius gives a circle within 0.03 %. */ +const KAPPA = 0.5522847498; + +interface Box { + x: number; + y: number; + w: number; + h: number; +} + +/** The rings the arguments give: rings as written, or a rect or ellipse turned into one. */ +export function outlineOf(args: Args): VectorRing[] { + const given = (['rings', 'rect', 'ellipse'] as const).filter((key) => args[key] !== undefined); + if (given.length !== 1) throw new CommandError('Give the outline as exactly one of rings, rect or ellipse'); + if (args.rect) return [rectRing(args.rect as Box)]; + if (args.ellipse) return [ellipseRing(args.ellipse as Box)]; + return (args.rings as (Omit & { closed?: boolean })[]).map((ring) => ({ + points: ring.points.map((point) => ({ ...point })), + closed: ring.closed !== false, + })); +} + +function rectRing({ x, y, w, h }: Box): VectorRing { + return { + points: [ + { x, y }, + { x: x + w, y }, + { x: x + w, y: y + h }, + { x, y: y + h }, + ], + closed: true, + }; +} + +function ellipseRing({ x, y, w, h }: Box): VectorRing { + const rx = w / 2; + const ry = h / 2; + const cx = x + rx; + const cy = y + ry; + const kx = KAPPA * rx; + const ky = KAPPA * ry; + const point = (px: number, py: number, out: { x: number; y: number }): VectorPoint => ({ + x: px, + y: py, + in: { x: -out.x, y: -out.y }, + out, + mode: 'symmetric', + }); + // Clockwise on screen from the top: right, bottom, left. + return { + points: [ + point(cx, y, { x: kx, y: 0 }), + point(x + w, cy, { x: 0, y: ky }), + point(cx, y + h, { x: -kx, y: 0 }), + point(x, cy, { x: 0, y: -ky }), + ], + closed: true, + }; +} + +interface PaintArg { + type: 'solid' | 'linear' | 'radial' | 'texture'; + from?: { x: number; y: number }; + to?: { x: number; y: number }; + stops?: { offset: number; color: string; alpha?: number }[]; + texture?: string; +} + +/** The style the arguments give, null when they name none; `shape` is the one it changes, if any. */ +function styleOf(project: Project, args: Args, shape: VectorShape | null): ShapeStyle | null { + const style: ShapeStyle = {}; + if (typeof args.fill === 'string') style.fill = hexColor(args.fill); + if (typeof args.opacity === 'number') style.opacity = args.opacity; + if (args.paint) style.paint = paintOf(project, args.paint as PaintArg, 'paint'); + const stroke = args.stroke as { color?: string; width: number; paint?: PaintArg } | undefined; + if (stroke) { + if (stroke.width <= 0) style.stroke = null; + else { + const paint = stroke.paint ? paintOf(project, stroke.paint, 'stroke.paint') : shape?.stroke?.paint; + if (paint?.type === 'texture') throw new CommandError('An outline takes a gradient, not a picture'); + style.stroke = { + color: stroke.color ? hexColor(stroke.color) : (shape?.stroke?.color ?? 0x000000), + width: stroke.width, + ...(paint ? { paint } : {}), + }; + } + } + return Object.keys(style).length > 0 ? style : null; +} + +function paintOf(project: Project, paint: PaintArg, at: string): VectorPaint | null { + if (paint.type === 'solid') return null; + if (!paint.from || !paint.to) throw new CommandError(`${at} of type ${paint.type} needs from and to`); + if (paint.type === 'texture') { + if (!paint.texture || !textureFillRef(project, paint.texture)) + throw new CommandError(`${at}.texture has to name a texture or one of its frames`); + return { type: 'texture', from: { ...paint.from }, to: { ...paint.to }, texture: paint.texture }; + } + if (!paint.stops) throw new CommandError(`${at} of type ${paint.type} needs stops`); + const stops: GradientStop[] = paint.stops.map((stop) => ({ + offset: Math.max(0, Math.min(1, stop.offset)), + color: hexColor(stop.color), + alpha: Math.max(0, Math.min(1, stop.alpha ?? 1)), + })); + const gradient: VectorGradient = { type: paint.type, from: { ...paint.from }, to: { ...paint.to }, stops }; + return gradient; +} + +/** Every shape the ids name, the shapes inside a named group too; refused when one names nothing. */ +function shapesOf(asset: VectorArtAsset, itemIds: readonly string[]): VectorShape[] { + requireItems(asset, itemIds); + const shapes = new Map(); + for (const itemId of itemIds) + for (const shape of shapesIn([findVectorItem(asset, itemId)!])) shapes.set(shape.id, shape); + return [...shapes.values()]; +} + +function requireItems(asset: VectorArtAsset, itemIds: readonly string[]): void { + const missing = itemIds.find((itemId) => !findVectorItem(asset, itemId)); + if (missing) throw new CommandError(`${asset.name} has no shape or group ${missing}`); +} + +/** Where moved items land: the layer, and the place in it – on top of a group, or right over an item. */ +function dropOf( + asset: VectorArtAsset, + args: Args, + moved: readonly string[], +): { layer: string; drop: TreeDrop | null } { + const into = args.into as string | undefined; + const over = args.over as string | undefined; + if (into && over) throw new CommandError('Give into or over, not both'); + const anchor = into ?? over; + const place = anchor ? placeOf(asset.layers, anchor) : null; + if (anchor && !place) throw new CommandError(`${asset.name} has no shape or group ${anchor}`); + const layer = (args.layer as string | undefined) ?? place?.layer.id; + if (!layer) throw new CommandError('Name the layer, or a group (into) or item (over) to move to'); + if (place && place.layer.id !== layer) + throw new CommandError(`${anchor} lies on another layer than ${layer}`); + if (into) { + if (!isVectorGroup(place!.item)) throw new CommandError(`${into} is a shape, not a group`); + const staying = place!.item.items.filter((item) => !moved.includes(item.id)); + return { layer, drop: { parentId: into, belowId: staying[staying.length - 1]?.id ?? null } }; + } + if (over) return { layer, drop: { parentId: place!.parent?.id ?? null, belowId: over } }; + return { layer, drop: null }; +} diff --git a/src/editor/agent/commandCatalog.ts b/src/editor/agent/commandCatalog.ts index 08f9eb39..b97e7874 100644 --- a/src/editor/agent/commandCatalog.ts +++ b/src/editor/agent/commandCatalog.ts @@ -2,11 +2,13 @@ import { ANIMATION_COMMANDS } from './catalog/animation'; import { ASSET_COMMANDS } from './catalog/assets'; import { GRAPH_COMMANDS } from './catalog/graphs'; import { OBJECT_COMMANDS } from './catalog/objects'; +import { PIXEL_ART_COMMANDS } from './catalog/pixelart'; import { PROJECT_COMMANDS } from './catalog/project'; import type { CatalogCommand } from './catalog/shared'; import { TEXTURE_COMMANDS } from './catalog/textures'; import { TILE_COMMANDS } from './catalog/tiles'; import { VARIABLE_COMMANDS } from './catalog/variables'; +import { VECTOR_ART_COMMANDS } from './catalog/vectorart'; export type { BuildContext, Built, CatalogCommand, Frame, StepTarget } from './catalog/shared'; @@ -51,6 +53,16 @@ export const AREAS: readonly CommandArea[] = [ about: 'Pictures of tiles; painting tilemaps, their layers, and the edges of a placed one.', commands: TILE_COMMANDS, }, + { + area: 'pixelart', + about: 'Painting Pixel art assets, their layers, frames and canvas size, and the colors of palettes.', + commands: PIXEL_ART_COMMANDS, + }, + { + area: 'vectorart', + about: 'Shapes of Vector art assets: their outlines, fills, gradients and strokes, groups and layers.', + commands: VECTOR_ART_COMMANDS, + }, { area: 'project', about: 'Settings of the project as a whole.', commands: PROJECT_COMMANDS }, ]; diff --git a/src/editor/agent/readTools.ts b/src/editor/agent/readTools.ts index 5824d0b9..7687c24e 100644 --- a/src/editor/agent/readTools.ts +++ b/src/editor/agent/readTools.ts @@ -1,16 +1,19 @@ import { type GraphAnnotation, type GraphNode, resolveDef } from '../../core/graph'; import type { PortDef } from '../../core/graph/NodeRegistry'; import { animationTargets, targetDefs } from '../../core/model/animation'; -import type { AnimationAsset, Asset } from '../../core/model/Asset'; +import type { AnimationAsset, Asset, PixelArtAsset } from '../../core/model/Asset'; import { ASSET_TYPE_LABELS, ASSET_TYPES, type AssetType, isPlaceableType } from '../../core/model/AssetTypes'; import { graphLookup } from '../../core/model/graphLookup'; import { isTemplateContent, resolveObjects } from '../../core/model/instance'; import { findAsset, findGraphAsset, type Project } from '../../core/model/Project'; +import { celOf, paletteColors } from '../../core/model/pixelart'; +import type { CelPixels } from '../../core/model/pixelCel'; import { objectName } from '../../core/model/SceneObject'; import { VARIABLE_KINDS } from '../../core/properties/kinds'; import { type PropertyDef, schemaFor, schemaForAsset } from '../../core/properties/PropertySchema'; import { type PaletteEntry, paletteEntries, rankedEntries } from '../graph/paletteEntries'; import { toHex } from '../../ui/color'; +import { celRows } from './catalog/pixelart'; import { AREAS, type CatalogCommand } from './commandCatalog'; /** @@ -51,8 +54,14 @@ export function listAssets(project: Project, type?: string, query?: string): obj })); } -/** An asset as stored, with the names of what a scene or container holds and the rows of its properties. */ -export function getAsset(project: Project, assetId: string): object | null { +/** Reads the pixels of a cel: null for an empty one, undefined while it loads. */ +export type CelLookup = (file: string) => CelPixels | null | undefined; + +/** + * An asset as stored, with the names of what a scene or container holds and the rows of its + * properties. Colors read as "#rrggbb", and `readCel` lets the pixels of small pixel art be read too. + */ +export function getAsset(project: Project, assetId: string, readCel?: CelLookup): object | null { const asset = findAsset(project, assetId); if (!asset) return null; const plain = JSON.parse(JSON.stringify(asset, trimmed)) as Record; @@ -66,6 +75,12 @@ export function getAsset(project: Project, assetId: string): object | null { })); } if (asset.type === 'animation') plain.targets = animationTargetRows(project, asset); + if (asset.type === 'palette') plain.colors = asset.colors.map(toHex); + if (asset.type === 'vectorart') plain.layers = hexColors(plain.layers); + if (asset.type === 'pixelart') { + plain.colors = paletteColors(project, asset).map(toHex); + plain.frames = pixelFrames(asset, readCel); + } plain.properties = schemaForAsset(project, asset).map(propertyRow); return plain; } @@ -88,6 +103,43 @@ function animationTargetRows(project: Project, animation: AnimationAsset): objec } const MAX_CELLS_IN_FULL = 400; +/** The most pixels one answer spells out, over all cels: four of 64x64. */ +export const MAX_PIXELS_IN_FULL = 16384; + +/** + * The frames of pixel art with what each layer holds there: its rows (see `celRows`) while the whole + * asset is small enough, else how many pixels are painted; "empty", or "loading" until it is read. + */ +function pixelFrames(asset: PixelArtAsset, readCel?: CelLookup): object[] { + const cels = asset.frames.length * asset.layers.length; + const spelled = readCel !== undefined && cels * asset.width * asset.height <= MAX_PIXELS_IN_FULL; + return asset.frames.map((frame) => ({ + id: frame.id, + layers: Object.fromEntries( + asset.layers.map((layer): [string, unknown] => { + const file = celOf(frame, layer.id); + if (!file) return [layer.id, 'empty']; + const cel = readCel?.(file); + if (cel === undefined) return [layer.id, readCel ? 'loading' : 'painted']; + if (cel === null) return [layer.id, 'empty']; + const painted = cel.data.reduce((n, index) => n + (index === 0 ? 0 : 1), 0); + return [layer.id, (spelled && celRows(cel)) || { painted }]; + }), + ), + })); +} + +/** The JSON of vector layers with every fill and color written as "#rrggbb". */ +function hexColors(value: unknown): unknown { + if (Array.isArray(value)) return value.map(hexColors); + if (value === null || typeof value !== 'object') return value; + return Object.fromEntries( + Object.entries(value).map(([key, inner]) => [ + key, + (key === 'fill' || key === 'color') && typeof inner === 'number' ? toHex(inner) : hexColors(inner), + ]), + ); +} /** Leaves out what is too big to read and has a tool of its own, or tells only how much there is. */ function trimmed(key: string, value: unknown): unknown { diff --git a/src/editor/agent/tools.json b/src/editor/agent/tools.json index aeaa6ee8..644228b3 100644 --- a/src/editor/agent/tools.json +++ b/src/editor/agent/tools.json @@ -44,7 +44,7 @@ { "name": "get_asset", "title": "Read an asset", - "description": "One asset as stored: its property values, the rows of its properties (key, kind, default), the objects of a scene or container with their names and positions (the content of a placed container too, marked fromContainer), what the rows of an animation may aim at (targets), the cells of a small tilemap. A graph is only counted here; read it with get_graph.", + "description": "One asset as stored: its property values, the rows of its properties (key, kind, default), the objects of a scene or container with their names and positions (the content of a placed container too, marked fromContainer), what the rows of an animation may aim at (targets), the cells of a small tilemap, the pixels of small pixel art as rows of palette indices (colors as \"#rrggbb\"). A graph is only counted here; read it with get_graph.", "inputSchema": { "type": "object", "properties": { "asset": { "type": "string", "description": "Id of the asset." } }, diff --git a/src/editor/pixelart/pixelActions.ts b/src/editor/pixelart/pixelActions.ts index dd933909..c2d2ad9d 100644 --- a/src/editor/pixelart/pixelActions.ts +++ b/src/editor/pixelart/pixelActions.ts @@ -3,10 +3,12 @@ * pure state edits; resizing and merging also need the pixels, which only this browser holds, so * their new cels are worked out here and handed to the edit by name. */ +import type { Command } from '../../core/commands/Command'; import { oneStep } from '../../core/commands/CompoundCommand'; import { EditPixelArtCommand, SetPaletteColorsCommand } from '../../core/commands/pixelArtCommands'; import { newId } from '../../core/ids'; import type { PaletteAsset, PixelArtAsset } from '../../core/model/Asset'; +import type { Project } from '../../core/model/Project'; import { celFilesOf, celOf, isPixelArt, PALETTE_MAX, paletteAssetOf } from '../../core/model/pixelart'; import { type CelPixels, emptyCel } from '../../core/model/pixelCel'; import { mergeCels, type ResizeAnchor, resizeCel, withoutColorIndex } from '../../core/model/pixelDraw'; @@ -47,7 +49,7 @@ function loadedCels(asset: PixelArtAsset): Map | null { return cels; } -const STILL_LOADING = 'The pixels are still loading – try again in a moment.'; +export const STILL_LOADING = 'The pixels are still loading – try again in a moment.'; /** The new layer's id, so the tab can go on painting on it. */ export function addLayer(state: EditorState, asset: PixelArtAsset, aboveId: string): string { @@ -101,17 +103,16 @@ export function moveLayer(state: EditorState, asset: PixelArtAsset, layerId: str /** The layer laid onto the one below it on every frame; its opacity is not kept (see `mergeCels`). */ export function mergeLayerDown(state: EditorState, asset: PixelArtAsset, layerId: string): boolean { + return run(state, mergeLayerDownCommand(asset, layerId)); +} + +/** The merge as one command, or why it cannot be done now. */ +export function mergeLayerDownCommand(asset: PixelArtAsset, layerId: string): Command | string { const index = asset.layers.findIndex((layer) => layer.id === layerId); const lower = asset.layers[index - 1]; - if (!lower) { - state.setNotice('There is no layer below to merge into.'); - return false; - } + if (!lower) return 'There is no layer below to merge into.'; const cels = loadedCels(asset); - if (!cels) { - state.setNotice(STILL_LOADING); - return false; - } + if (!cels) return STILL_LOADING; const empty = emptyCel(asset.width, asset.height); const merged: Record = {}; for (const frame of asset.frames) { @@ -119,7 +120,9 @@ export function mergeLayerDown(state: EditorState, asset: PixelArtAsset, layerId const above = cels.get(celOf(frame, layerId)) ?? empty; merged[frame.id] = pixelCels.put(mergeCels(below, above)); } - return edit(state, asset, 'Merge layer down', (s) => withLayerMergedDown(s, layerId, merged)); + return new EditPixelArtCommand(asset.id, 'Merge layer down', (s) => + withLayerMergedDown(s, layerId, merged), + ); } /** A new frame after `afterId`: empty, or a copy of it. Returns its id. */ @@ -147,15 +150,23 @@ export function resizeCanvas( height: number, anchor: ResizeAnchor, ): boolean { - if (width === asset.width && height === asset.height) return false; + const command = resizeCanvasCommand(asset, width, height, anchor); + return command !== null && run(state, command); +} + +/** The resize as one command, why it cannot be done now, or null when the size is the same. */ +export function resizeCanvasCommand( + asset: PixelArtAsset, + width: number, + height: number, + anchor: ResizeAnchor, +): Command | string | null { + if (width === asset.width && height === asset.height) return null; const cels = loadedCels(asset); - if (!cels) { - state.setNotice(STILL_LOADING); - return false; - } + if (!cels) return STILL_LOADING; const resized: Record = {}; for (const [file, cel] of cels) resized[file] = pixelCels.put(resizeCel(cel, width, height, anchor)); - return edit(state, asset, 'Resize canvas', (s) => withCanvasSize(s, width, height, resized)); + return new EditPixelArtCommand(asset.id, 'Resize canvas', (s) => withCanvasSize(s, width, height, resized)); } export function setPaletteColor( @@ -186,17 +197,24 @@ export function addPaletteColor(state: EditorState, palette: PaletteAsset, color * after it move down one. Palette and pictures change in one undo step. */ export function removePaletteColor(state: EditorState, palette: PaletteAsset, index: number): boolean { - if (index < 1 || index > palette.colors.length) return false; - const users = state.project.assets.filter( - (asset): asset is PixelArtAsset => isPixelArt(asset) && paletteAssetOf(state.project, asset) === palette, + const command = removePaletteColorCommand(state.project, palette, index); + return command !== null && run(state, command); +} + +/** The removal as one command, why it cannot be done now, or null for an index the palette lacks. */ +export function removePaletteColorCommand( + project: Project, + palette: PaletteAsset, + index: number, +): Command | string | null { + if (index < 1 || index > palette.colors.length) return null; + const users = project.assets.filter( + (asset): asset is PixelArtAsset => isPixelArt(asset) && paletteAssetOf(project, asset) === palette, ); const rewrites: EditPixelArtCommand[] = []; for (const asset of users) { const cels = loadedCels(asset); - if (!cels) { - state.setNotice(STILL_LOADING); - return false; - } + if (!cels) return STILL_LOADING; const replaced: Record = {}; for (const [file, cel] of cels) { const next = withoutColorIndex(cel, index); @@ -207,5 +225,14 @@ export function removePaletteColor(state: EditorState, palette: PaletteAsset, in } const colors = palette.colors.filter((_, i) => i !== index - 1); const recolor = new SetPaletteColorsCommand(palette.id, 'Remove color', colors); - return state.execute(oneStep('Remove color', [recolor, ...rewrites])); + return oneStep('Remove color', [recolor, ...rewrites]); +} + +/** Runs a command, or shows why there is none. */ +function run(state: EditorState, command: Command | string): boolean { + if (typeof command === 'string') { + state.setNotice(command); + return false; + } + return state.execute(command); } diff --git a/tests/editor/agent-drawing.test.ts b/tests/editor/agent-drawing.test.ts new file mode 100644 index 00000000..311c7293 --- /dev/null +++ b/tests/editor/agent-drawing.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, it } from 'vitest'; +import type { PaletteAsset, PixelArtAsset, VectorArtAsset, VectorShape } from '../../src/core/model/Asset'; +import { celOf } from '../../src/core/model/pixelart'; +import { flattenRing } from '../../src/core/model/vectorBoolean'; +import { outlineExtent } from '../../src/core/model/vectorTransform'; +import { allShapes } from '../../src/core/model/vectorTree'; +import { runTool } from '../../src/editor/agent/agentTools'; +import { pixelCels } from '../../src/viewport/PixelCels'; +import { makeSpriteScene, type SceneFixture } from '../helpers'; +import { hostFor, text } from './agentTestHost'; + +/** The commands of #429 stage 5c: pixel art, palettes and vector art. */ + +const run = async (fixture: SceneFixture, steps: object[], follows = false) => { + const { host, shown } = hostFor(fixture, null, follows); + const result = await runTool('run_commands', { steps }, host); + return { result, shown, text: text(result) }; +}; + +const read = async (fixture: SceneFixture, asset: string) => + (await runTool('get_asset', { asset }, hostFor(fixture).host)).structuredContent as Record; + +const ofType = (fixture: SceneFixture, type: string) => + fixture.project.assets.find((asset) => asset.type === type) as T; + +/** The pixels of a cel as rows of indices, for asserting without the alphabet of get_asset. */ +function pixels(asset: PixelArtAsset, frame = 0, layer = 0): number[][] { + const cel = pixelCels.read(celOf(asset.frames[frame]!, asset.layers[layer]!.id)); + if (!cel) return []; + return Array.from({ length: cel.height }, (_, y) => [ + ...cel.data.subarray(y * cel.width, (y + 1) * cel.width), + ]); +} + +async function smallPixelArt(fixture: SceneFixture, side = 6): Promise { + const made = await run(fixture, [ + { command: 'create_asset', args: { type: 'pixelart', name: 'Hero' }, as: 'art' }, + { command: 'resize_pixel_canvas', args: { asset: '$art', width: side, height: side } }, + ]); + expect(made.result.isError).toBeUndefined(); + return ofType(fixture, 'pixelart'); +} + +describe('pixel art', () => { + it('paints rows, a rect and a fill in order, reads them back as rows and shows the tab', async () => { + const fixture = makeSpriteScene(); + const art = await smallPixelArt(fixture); + const { result, shown } = await run( + fixture, + [ + { + command: 'paint_pixels', + args: { + asset: art.id, + draws: [ + { draw: 'rect', color: 2, from: { x: 0, y: 0 }, to: { x: 5, y: 5 } }, + { draw: 'fill', color: 8, at: { x: 2, y: 2 } }, + { draw: 'rows', at: { x: 1, y: 1 }, rows: ['a b', ' . '] }, + ], + }, + }, + ], + true, + ); + expect(result.isError).toBeUndefined(); + expect(shown).toEqual([{ kind: 'asset', asset: art.id, open: true }]); + const frames = (await read(fixture, art.id)).frames as { id: string; layers: Record }[]; + expect(frames).toHaveLength(1); + expect(frames[0]!.layers[art.layers[0]!.id]).toEqual([ + '222222', + '2a8b82', + '28.882', + '288882', + '288882', + '222222', + ]); + }); + + it('reads the palette as colors and refuses an index the palette lacks, a pixel outside, an unknown character', async () => { + const fixture = makeSpriteScene(); + const art = await smallPixelArt(fixture, 4); + const colors = (await read(fixture, art.id)).colors as string[]; + expect(colors).toHaveLength(16); + expect(colors[0]).toBe('#000000'); + const paint = (draw: object) => + run(fixture, [{ command: 'paint_pixels', args: { asset: art.id, draws: [draw] } }]); + expect((await paint({ draw: 'pixels', color: 17, points: [{ x: 0, y: 0 }] })).text).toMatch( + /draws\[0\]: The palette has 16 colors, so 17 is no index/, + ); + expect((await paint({ draw: 'pixels', color: 1, points: [{ x: 4, y: 0 }] })).text).toMatch( + /4,0 lies outside the 4x4 canvas/, + ); + expect((await paint({ draw: 'rows', rows: ['..#.'] })).text).toMatch(/"#" in row 0 is no index/); + expect((await paint({ draw: 'rows', rows: ['.....'] })).text).toMatch(/reach past the 4x4 canvas/); + expect(pixels(art)).toEqual([]); + }); + + it('paints a copied frame and another layer, merges the layer down and resizes round the middle', async () => { + const fixture = makeSpriteScene(); + const art = await smallPixelArt(fixture, 4); + const first = art.frames[0]!.id; + const { result } = await run(fixture, [ + { command: 'paint_pixels', args: { asset: art.id, draws: [{ draw: 'rows', rows: ['1111'] }] } }, + { command: 'add_pixel_frame', args: { asset: art.id, after: first, copy: true }, as: 'second' }, + { command: 'add_pixel_layer', args: { asset: art.id }, as: 'top' }, + { + command: 'paint_pixels', + args: { asset: art.id, layer: '$top', frame: '$second', draws: [{ draw: 'rows', rows: ['.3'] }] }, + }, + { command: 'merge_pixel_layer_down', args: { asset: art.id, layer: '$top' } }, + { command: 'resize_pixel_canvas', args: { asset: art.id, width: 6, height: 4, anchorX: 'center' } }, + ]); + expect(result.isError).toBeUndefined(); + expect(art.layers).toHaveLength(1); + expect(art.width).toBe(6); + expect(pixels(art, 0)[0]).toEqual([0, 1, 1, 1, 1, 0]); + expect(pixels(art, 1)[0]).toEqual([0, 1, 3, 1, 1, 0]); + }); + + it('takes a palette color out with its pixels, the later ones moving down an index', async () => { + const fixture = makeSpriteScene(); + const art = await smallPixelArt(fixture, 3); + const { result } = await run(fixture, [ + { command: 'create_asset', args: { type: 'palette', name: 'Mine' }, as: 'pal' }, + { command: 'set_palette_colors', args: { asset: '$pal', colors: ['#ff0000', '#00ff00', '#0000ff'] } }, + { command: 'set_asset_property', args: { asset: art.id, key: 'palette', value: '$pal' } }, + { command: 'paint_pixels', args: { asset: art.id, draws: [{ draw: 'rows', rows: ['123'] }] } }, + { command: 'remove_palette_color', args: { asset: '$pal', index: 2 } }, + ]); + expect(result.isError).toBeUndefined(); + expect(ofType(fixture, 'palette').colors).toEqual([0xff0000, 0x0000ff]); + expect(pixels(art)[0]).toEqual([1, 0, 2]); + expect((await read(fixture, art.id)).colors).toEqual(['#ff0000', '#0000ff']); + }); + + it('a big canvas tells only how many pixels are painted', async () => { + const fixture = makeSpriteScene(); + const art = await smallPixelArt(fixture, 200); + await run(fixture, [ + { + command: 'paint_pixels', + args: { + asset: art.id, + draws: [{ draw: 'rect', color: 1, from: { x: 0, y: 0 }, to: { x: 9, y: 9 }, filled: true }], + }, + }, + ]); + const frames = (await read(fixture, art.id)).frames as { layers: Record }[]; + expect(frames[0]!.layers[art.layers[0]!.id]).toEqual({ painted: 100 }); + }); +}); + +describe('vector art', () => { + async function drawing(fixture: SceneFixture, steps: object[]) { + const made = await run(fixture, [ + { command: 'create_asset', args: { type: 'vectorart', name: 'Logo' }, as: 'art' }, + ...steps, + ]); + return { ...made, art: ofType(fixture, 'vectorart') }; + } + + it('adds an ellipse with fill and outline, which reaches exactly its box and reads in "#rrggbb"', async () => { + const fixture = makeSpriteScene(); + const { result, art } = await drawing(fixture, [ + { + command: 'add_vector_shape', + args: { + asset: '$art', + name: 'Sun', + ellipse: { x: 10, y: 20, w: 100, h: 60 }, + fill: '#ffcc00', + stroke: { color: '#aa5500', width: 3 }, + }, + }, + ]); + expect(result.isError).toBeUndefined(); + const [shape] = allShapes(art); + expect(shape).toMatchObject({ name: 'Sun', fill: 0xffcc00, stroke: { color: 0xaa5500, width: 3 } }); + const extent = outlineExtent([shape!])!; + for (const [got, want] of [ + [extent.x0, 10], + [extent.y0, 20], + [extent.x1, 110], + [extent.y1, 80], + ] as const) + expect(got).toBeCloseTo(want, 6); + // Every sample of the drawn curve lies on the ellipse, not only the four points it is made of. + const samples = flattenRing(shape!.rings[0]!); + expect(samples.length).toBeGreaterThan(8); + for (const { x, y } of samples) expect(Math.hypot((x - 60) / 50, (y - 50) / 30)).toBeCloseTo(1, 3); + const layers = (await read(fixture, art.id)).layers as { + items: { fill: string; stroke: { color: string } }[]; + }[]; + expect(layers[0]!.items[0]).toMatchObject({ fill: '#ffcc00', stroke: { color: '#aa5500' } }); + }); + + it('moves and turns a rect round its middle, and refuses two outlines at once', async () => { + const fixture = makeSpriteScene(); + const { art } = await drawing(fixture, [ + { command: 'add_vector_shape', args: { asset: '$art', rect: { x: 0, y: 0, w: 40, h: 20 } }, as: 'box' }, + { + command: 'transform_vector_shapes', + args: { asset: '$art', shapes: ['$box'], rotate: 90, move: { x: 5, y: 0 } }, + }, + ]); + const extent = outlineExtent(allShapes(art))!; + // 40x20 round (20,10) turned a quarter is 20x40 round the same middle, then 5 to the right. + expect([extent.x0, extent.y0, extent.x1, extent.y1].map((v) => Math.round(v * 1e6) / 1e6)).toEqual([ + 15, -10, 35, 30, + ]); + const twice = await run(fixture, [ + { + command: 'add_vector_shape', + args: { asset: art.id, rect: { x: 0, y: 0, w: 1, h: 1 }, ellipse: { x: 0, y: 0, w: 1, h: 1 } }, + }, + ]); + expect(twice.text).toMatch(/exactly one of rings, rect or ellipse/); + }); + + it('a gradient fill goes back to solid, and an outline of width 0 goes away', async () => { + const fixture = makeSpriteScene(); + const { art } = await drawing(fixture, [ + { + command: 'add_vector_shape', + args: { asset: '$art', rect: { x: 0, y: 0, w: 10, h: 10 }, stroke: { width: 2 } }, + as: 'box', + }, + { + command: 'set_vector_shape_style', + args: { + asset: '$art', + shape: '$box', + paint: { + type: 'linear', + from: { x: 0, y: 0 }, + to: { x: 10, y: 0 }, + stops: [ + { offset: 0, color: '#ff0000' }, + { offset: 1, color: '#0000ff', alpha: 0.5 }, + ], + }, + }, + }, + ]); + const shape = (): VectorShape => allShapes(art)[0]!; + expect(shape().paint).toMatchObject({ + type: 'linear', + stops: [{ color: 0xff0000, alpha: 1 }, { alpha: 0.5 }], + }); + expect(shape().stroke).toEqual({ color: 0x000000, width: 2 }); + await run(fixture, [ + { + command: 'set_vector_shape_style', + args: { asset: art.id, shape: shape().id, paint: { type: 'solid' }, stroke: { width: 0 } }, + }, + ]); + expect(shape().paint).toBeUndefined(); + expect(shape().stroke).toBeUndefined(); + }); + + it('moves a shape on top of a group, and refuses to remove a shape that is not there', async () => { + const fixture = makeSpriteScene(); + const box = (name: string) => ({ + command: 'add_vector_shape', + args: { asset: '$art', name, rect: { x: 0, y: 0, w: 5, h: 5 } }, + as: name, + }); + const { result, art } = await drawing(fixture, [ + box('a'), + box('b'), + box('c'), + { command: 'group_vector_shapes', args: { asset: '$art', shapes: ['$a', '$b'] }, as: 'g' }, + { command: 'move_vector_shapes', args: { asset: '$art', shapes: ['$c'], into: '$g' } }, + ]); + expect(result.isError).toBeUndefined(); + const group = art.layers[0]!.items[0] as { items: { name: string }[] }; + expect(art.layers[0]!.items).toHaveLength(1); + expect(group.items.map((item) => item.name)).toEqual(['a', 'b', 'c']); + const missing = await run(fixture, [ + { command: 'remove_vector_shapes', args: { asset: art.id, shapes: [allShapes(art)[0]!.id, 'nope'] } }, + ]); + expect(missing.text).toMatch(/Logo has no shape or group nope/); + expect(allShapes(art)).toHaveLength(3); + }); +}); diff --git a/tools/e2e/71-mcp.mjs b/tools/e2e/71-mcp.mjs index 75050173..30fae924 100644 --- a/tools/e2e/71-mcp.mjs +++ b/tools/e2e/71-mcp.mjs @@ -32,7 +32,7 @@ const TOOLS = [ */ export async function mcp(t) { const { check, json, sleep, waitUntil, settle, press, locate, click, cdp, baseUrl, projectId } = t; - const { clickText, menuTicked, exceptions, navigate, shot, holdKey, entity } = t; + const { clickText, menuTicked, exceptions, navigate, shot, holdKey, entity, evaluate } = t; const site = process.env.FEUERWERK_MCP_BASE_URL ?? baseUrl; const endpoint = `${site}mcp`; @@ -417,6 +417,52 @@ export async function mcp(t) { }), ); + // 71g7. Drawing (#429 stage 5c): pixel art the agent paints comes up in its tab, reads back as rows, + // and its cel reaches the server when the project is saved. + const painted = await callTool('run_commands', { + label: 'paint', + steps: [ + { command: 'create_asset', args: { type: 'pixelart', name: `${TOKEN_NAME} pixels` }, as: 'art' }, + { command: 'resize_pixel_canvas', args: { asset: '$art', width: 4, height: 3 } }, + { + command: 'paint_pixels', + args: { asset: '$art', draws: [{ draw: 'rows', rows: ['8..8', '.88.', '8..8'] }] }, + }, + ], + }); + await settle(); + const artId = painted?.structuredContent?.made?.find((entry) => entry.step === 1)?.id; + const artView = await viewNow(); + const artRead = artId ? (await callTool('get_asset', { asset: artId }))?.structuredContent : null; + const artRows = artRead ? Object.values(artRead.frames?.[0]?.layers ?? {})[0] : null; + await waitUntil('__gm.state.saveStatus === "saved"', { timeout: 10000, label: 'saved' }).catch(() => {}); + const celFiles = artId + ? await json( + `Object.values(__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(artId)}).frames[0].cels)`, + ) + : []; + const celStatus = celFiles[0] + ? Number(await evaluate(`fetch('/feuerwerk/data/pixels/${celFiles[0]}').then((r) => String(r.status))`)) + : null; + await callTool('undo'); + await callTool('open', { asset: walkId }); + await settle(); + check( + 'pixel art the agent paints comes up in its tab, reads back as rows, and its cel is saved to the server', + painted?.isError === false && + artView?.document?.asset?.id === artId && + JSON.stringify(artRows) === JSON.stringify(['8..8', '.88.', '8..8']) && + celFiles.length === 1 && + celStatus === 200, + JSON.stringify({ + text: painted?.content?.[0]?.text?.slice(0, 300), + document: artView?.document, + artRows, + celFiles, + celStatus, + }), + ); + // 71h. Played by the agent, the object walks left while the user holds the arrow. const started = await callTool('play', { action: 'play' }); await waitUntil('__gm.state.mode === "play"', { timeout: 10000, label: 'the game runs' });