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); }); });