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 <noreply@anthropic.com>
286 lines
12 KiB
TypeScript
286 lines
12 KiB
TypeScript
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<string, unknown>;
|
|
|
|
const ofType = <T>(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<PixelArtAsset> {
|
|
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<PixelArtAsset>(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<string, unknown> }[];
|
|
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<PaletteAsset>(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<string, unknown> }[];
|
|
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<VectorArtAsset>(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);
|
|
});
|
|
});
|