import { afterEach, describe, expect, it } from 'vitest'; import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; import { AddVectorShapeCommand, DuplicateVectorShapeCommand, MoveVectorShapeCommand, RemoveVectorShapeCommand, RenameVectorShapeCommand, SetVectorShapeRingsCommand, SetVectorShapeStyleCommand, } from '../../src/core/commands/vectorArtCommands'; import type { VectorArtAsset, VectorRing } from '../../src/core/model/Asset'; import { isPlaceableType } from '../../src/core/model/AssetTypes'; import { generatedPictureOf, registerPictureSource } from '../../src/core/model/generated'; import { findAsset, type Project } from '../../src/core/model/Project'; import { resolvePicture } from '../../src/core/model/texture'; import { handleAt, isDrawableRing, SHAPE_FILL_DEFAULT, shapeExtent, shapeOrderTarget, VECTOR_CANVAS_DEFAULT, VECTOR_CANVAS_MAX, VECTOR_HEIGHT_KEY, VECTOR_WIDTH_KEY, vectorArtPicture, vectorArtRevision, vectorCanvasSize, } from '../../src/core/model/vectorart'; import { makeProject, roundTrip } from '../helpers'; /** A square, and the same square with one point bent into a curve. */ function square(): VectorRing { return { closed: true, points: [ { x: 10, y: 10 }, { x: 90, y: 10 }, { x: 90, y: 90 }, { x: 10, y: 90 }, ], }; } function drawing() { const fixture = makeProject(); const create = new CreateAssetCommand('vectorart', 'Logo'); fixture.stack.execute(create); const assetId = create.asset.id; const art = (project: Project = fixture.project): VectorArtAsset => findAsset(project, assetId) as VectorArtAsset; return { ...fixture, assetId, art }; } describe('the vector art asset', () => { it('is a picture, not something placed in a scene', () => { expect(isPlaceableType('vectorart')).toBe(false); }); it('starts empty on a canvas of its own', () => { const f = drawing(); expect(f.art().shapes).toEqual([]); expect(vectorCanvasSize(f.art().props)).toEqual({ width: VECTOR_CANVAS_DEFAULT, height: VECTOR_CANVAS_DEFAULT, }); }); it('reads a canvas that is missing, broken or absurd as one it can draw on', () => { expect(vectorCanvasSize({})).toEqual({ width: 256, height: 256 }); expect(vectorCanvasSize({ [VECTOR_WIDTH_KEY]: 0, [VECTOR_HEIGHT_KEY]: -40 })).toEqual({ width: 256, height: 256, }); expect(vectorCanvasSize({ [VECTOR_WIDTH_KEY]: 99999, [VECTOR_HEIGHT_KEY]: 64.7 })).toEqual({ width: VECTOR_CANVAS_MAX, height: 64, }); }); }); describe('what is worth drawing', () => { it('needs three points closed and two open', () => { expect(isDrawableRing({ closed: true, points: square().points.slice(0, 2) })).toBe(false); expect(isDrawableRing({ closed: true, points: square().points.slice(0, 3) })).toBe(true); expect(isDrawableRing({ closed: false, points: square().points.slice(0, 2) })).toBe(true); }); it('measures a shape including the handles pulled off its points', () => { const shape = { id: 's', name: 'S', rings: [ { closed: true, points: [ { x: 10, y: 10, out: { x: -30, y: 5 } }, { x: 90, y: 90 }, ], }, ], fill: 0, opacity: 1, }; expect(handleAt(shape.rings[0]!.points[0]!, 'out')).toEqual({ x: -20, y: 15 }); expect(handleAt(shape.rings[0]!.points[1]!, 'in')).toBeNull(); expect(shapeExtent(shape)).toEqual({ x0: -20, y0: 10, x1: 90, y1: 90 }); expect(shapeExtent({ ...shape, rings: [] })).toBeNull(); }); }); describe('the revision', () => { it('changes with every pixel and with nothing else', () => { const f = drawing(); const add = new AddVectorShapeCommand(f.assetId, [square()]); f.stack.execute(add); const first = vectorArtRevision(f.art()); f.stack.execute(new RenameVectorShapeCommand(f.assetId, add.shapeId, 'Body')); expect(vectorArtRevision(f.art())).toBe(first); // A point's type only says what the next drag does to its handles (#195). const typed = square(); typed.points[0] = { ...typed.points[0]!, mode: 'smooth' }; f.stack.execute(new SetVectorShapeRingsCommand(f.assetId, add.shapeId, [typed])); expect(vectorArtRevision(f.art())).toBe(first); f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { fill: 0xff0000 })); const coloured = vectorArtRevision(f.art()); expect(coloured).not.toBe(first); const moved = square(); moved.points[0] = { x: 11, y: 10 }; f.stack.execute(new SetVectorShapeRingsCommand(f.assetId, add.shapeId, [moved])); expect(vectorArtRevision(f.art())).not.toBe(coloured); }); it('changes when the canvas is resized, because the picture is a different one', () => { const f = drawing(); f.stack.execute(new AddVectorShapeCommand(f.assetId, [square()])); const before = vectorArtRevision(f.art()); f.stack.execute(new SetAssetPropertyCommand(f.assetId, VECTOR_WIDTH_KEY, 512)); expect(vectorArtRevision(f.art())).not.toBe(before); }); it('is the same for the same drawing, however the object was built', () => { const asset = (ring: VectorRing): VectorArtAsset => ({ id: 'a', type: 'vectorart', name: 'A', props: {}, shapes: [{ id: 's', name: 'S', rings: [ring], fill: 1, opacity: 1 }], }); // The same ring with its two keys set the other way round – what a project file gives back. const built = { closed: true, points: square().points }; const loaded = { points: square().points, closed: true } as VectorRing; expect(vectorArtRevision(asset(loaded))).toBe(vectorArtRevision(asset(built))); }); it('survives a save and load: the same drawing is the same picture', () => { const f = drawing(); f.stack.execute(new AddVectorShapeCommand(f.assetId, [square()])); expect(vectorArtRevision(f.art(roundTrip(f.project)))).toBe(vectorArtRevision(f.art())); }); }); describe('the way into the picture path', () => { let unregister: (() => void) | null = null; afterEach(() => { unregister?.(); unregister = null; }); it('hands over the canvas and the revision, and only for vector art', () => { const f = drawing(); expect(vectorArtPicture(f.art())).toEqual({ width: VECTOR_CANVAS_DEFAULT, height: VECTOR_CANVAS_DEFAULT, revision: vectorArtRevision(f.art()), }); const other = new CreateAssetCommand('sprite', 'Box'); f.stack.execute(other); expect(vectorArtPicture(other.asset)).toBeNull(); }); it('resolves to a generated picture of the canvas size once it is registered (#185)', () => { const f = drawing(); f.stack.execute(new SetAssetPropertyCommand(f.assetId, VECTOR_HEIGHT_KEY, 128)); expect(generatedPictureOf(f.art())).toBeNull(); unregister = registerPictureSource('vectorart', vectorArtPicture); const ref = resolvePicture(f.project, f.assetId); expect(ref?.file).toBe(`gen:${f.assetId}@${vectorArtRevision(f.art())}`); expect(ref?.sourceWidth).toBe(VECTOR_CANVAS_DEFAULT); expect(ref?.sourceHeight).toBe(128); }); }); describe('editing the shapes', () => { it('numbers new shapes and takes them back out again', () => { const f = drawing(); const first = new AddVectorShapeCommand(f.assetId); const second = new AddVectorShapeCommand(f.assetId, [square()], 0x112233); f.stack.execute(first); f.stack.execute(second); expect(f.art().shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']); expect(f.art().shapes[1]).toMatchObject({ fill: 0x112233, opacity: 1 }); expect(f.art().shapes[0]!.fill).toBe(SHAPE_FILL_DEFAULT); f.stack.undo(); expect(f.art().shapes.map((s) => s.name)).toEqual(['Shape 1']); f.stack.redo(); expect(f.art().shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']); }); it('puts a removed shape back where it stood, unlike a tilemap keeping its last layer', () => { const f = drawing(); const ids = [0, 1, 2].map(() => { const add = new AddVectorShapeCommand(f.assetId, [square()]); f.stack.execute(add); return add.shapeId; }); f.stack.execute(new RemoveVectorShapeCommand(f.assetId, ids[1]!)); expect(f.art().shapes.map((s) => s.id)).toEqual([ids[0], ids[2]]); f.stack.undo(); expect(f.art().shapes.map((s) => s.id)).toEqual(ids); // The last one may go: an empty drawing is a drawing. for (const id of ids) f.stack.execute(new RemoveVectorShapeCommand(f.assetId, id)); expect(f.art().shapes).toEqual([]); }); it('duplicates a shape right above it, named as a copy, with outlines of its own', () => { const f = drawing(); const add = new AddVectorShapeCommand(f.assetId, [square()], 0x112233); const top = new AddVectorShapeCommand(f.assetId, [square()]); f.stack.execute(add); f.stack.execute(top); f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { opacity: 0.5 })); const copy = new DuplicateVectorShapeCommand(f.assetId, add.shapeId); f.stack.execute(copy); f.stack.execute(new DuplicateVectorShapeCommand(f.assetId, add.shapeId)); const shapes = f.art().shapes; expect(shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 1 copy 2', 'Shape 1 copy', 'Shape 2']); expect(shapes[2]).toMatchObject({ id: copy.shapeId, fill: 0x112233, opacity: 0.5 }); expect(shapes[2]!.rings).toEqual(shapes[0]!.rings); // Its own outline: a point dragged on the copy leaves the original where it is. expect(shapes[2]!.rings[0]).not.toBe(shapes[0]!.rings[0]); f.stack.undo(); f.stack.undo(); expect(f.art().shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']); expect(() => f.stack.execute(new DuplicateVectorShapeCommand(f.assetId, 'nobody'))).toThrow(/not found/); }); it('keeps the drawing order apart from the list order in no way at all', () => { const f = drawing(); const ids = ['a', 'b', 'c'].map(() => { const add = new AddVectorShapeCommand(f.assetId, [square()]); f.stack.execute(add); return add.shapeId; }); // Last is on top, so "forward" is one step towards the end and "front" is the end. expect(shapeOrderTarget(f.art().shapes, ids[0]!, 'forward')).toBe(1); expect(shapeOrderTarget(f.art().shapes, ids[0]!, 'front')).toBe(2); expect(shapeOrderTarget(f.art().shapes, ids[2]!, 'backward')).toBe(1); expect(shapeOrderTarget(f.art().shapes, ids[1]!, 'back')).toBe(0); // Already there, and not there at all. expect(shapeOrderTarget(f.art().shapes, ids[2]!, 'front')).toBe(-1); expect(shapeOrderTarget(f.art().shapes, ids[0]!, 'back')).toBe(-1); expect(shapeOrderTarget(f.art().shapes, 'nobody', 'front')).toBe(-1); f.stack.execute(new MoveVectorShapeCommand(f.assetId, 0, 2)); expect(f.art().shapes.map((s) => s.id)).toEqual([ids[1], ids[2], ids[0]]); f.stack.undo(); expect(f.art().shapes.map((s) => s.id)).toEqual(ids); }); it('copies the outline it is handed, so a later drag does not reach into the undo step', () => { const f = drawing(); const add = new AddVectorShapeCommand(f.assetId, [square()]); f.stack.execute(add); const rings = [square()]; rings[0]!.points[0] = { x: 0, y: 0, out: { x: 5, y: 5 } }; const edit = new SetVectorShapeRingsCommand(f.assetId, add.shapeId, rings); expect(SetVectorShapeRingsCommand.changes(f.art().shapes[0]!, rings)).toBe(true); f.stack.execute(edit); rings[0]!.points[1] = { x: -999, y: -999 }; expect(f.art().shapes[0]!.rings[0]!.points[0]).toEqual({ x: 0, y: 0, out: { x: 5, y: 5 } }); expect(f.art().shapes[0]!.rings[0]!.points[1]).toEqual({ x: 90, y: 10 }); f.stack.undo(); expect(f.art().shapes[0]!.rings[0]!.points[0]).toEqual({ x: 10, y: 10 }); expect(SetVectorShapeRingsCommand.changes(f.art().shapes[0]!, [square()])).toBe(false); }); it('writes fill, opacity and outline one field at a time and takes each back', () => { const f = drawing(); const add = new AddVectorShapeCommand(f.assetId, [square()]); f.stack.execute(add); f.stack.execute( new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { fill: 0x00ff00, opacity: 4, stroke: { color: 0, width: 2 }, }), ); // An opacity outside 0…1 is clamped, not refused: a slider may overshoot. expect(f.art().shapes[0]).toMatchObject({ fill: 0x00ff00, opacity: 1 }); expect(f.art().shapes[0]!.stroke).toEqual({ color: 0, width: 2 }); f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { stroke: null })); expect(f.art().shapes[0]!.stroke).toBeUndefined(); f.stack.undo(); expect(f.art().shapes[0]!.stroke).toEqual({ color: 0, width: 2 }); f.stack.undo(); expect(f.art().shapes[0]).toMatchObject({ fill: SHAPE_FILL_DEFAULT, opacity: 1 }); expect(f.art().shapes[0]!.stroke).toBeUndefined(); }); it('refuses an outline of no width and a shape of no name', () => { const f = drawing(); const add = new AddVectorShapeCommand(f.assetId, [square()]); f.stack.execute(add); expect(() => f.stack.execute( new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { stroke: { color: 0, width: 0 } }), ), ).toThrow(/wider than nothing/); expect(() => f.stack.execute(new RenameVectorShapeCommand(f.assetId, add.shapeId, ' '))).toThrow( /needs a name/, ); expect(() => f.stack.execute(new RenameVectorShapeCommand(f.assetId, 'nobody', 'X'))).toThrow( /not found/, ); }); }); describe('what a project file may say', () => { it('keeps a whole drawing across save and load', () => { const f = drawing(); const add = new AddVectorShapeCommand(f.assetId, [ { closed: true, points: [ { x: 10, y: 10, out: { x: 4, y: 0 } }, { x: 90, y: 10, in: { x: -3, y: 0 }, out: { x: 6, y: 0 }, mode: 'smooth' }, { x: 50, y: 80, in: { x: -2, y: 1 }, out: { x: 2, y: -1 }, mode: 'symmetric' }, ], }, { closed: false, points: [ { x: 20, y: 20 }, { x: 30, y: 30 }, ], }, ]); f.stack.execute(add); f.stack.execute( new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { fill: 0x334455, opacity: 0.5, stroke: { color: 0xffffff, width: 3 }, }), ); expect(f.art(roundTrip(f.project)).shapes).toEqual(f.art().shapes); }); it('drops what cannot be drawn from and keeps what someone is still drawing', () => { const f = drawing(); const art = f.art() as unknown as Record; art.shapes = [ { id: 'one', name: 'One', rings: [ { closed: true, points: [ { x: 10, y: 10, in: { x: 'left', y: 2 }, out: { x: 1, y: 2 }, mode: 'wobbly' }, { x: 'far', y: 10 }, { x: 30, y: 30 }, { x: 50 }, ], }, { points: [{ x: 0, y: 0 }] }, ], fill: 0x123456, opacity: 7, stroke: { color: 0xffffff, width: 0 }, }, // A doubled id would make one reference mean two shapes, as a doubled layer id does. { id: 'one', name: 'Twin', rings: [] }, { name: 'Nameless', rings: [] }, 'not a shape', ]; const loaded = f.art(roundTrip(f.project)); expect(loaded.shapes).toHaveLength(1); const shape = loaded.shapes[0]!; // The broken handle falls away and leaves a corner, and so does a point type nobody knows (#195); // the broken points fall away entirely. expect(shape.rings[0]!.points).toEqual([ { x: 10, y: 10, out: { x: 1, y: 2 } }, { x: 30, y: 30 }, ]); // A ring of one point is kept – it is an outline someone is still drawing – and closed by default. expect(shape.rings[1]).toEqual({ closed: true, points: [{ x: 0, y: 0 }] }); expect(shape).toMatchObject({ fill: 0x123456, opacity: 1 }); // An outline of no width is no outline. expect(shape.stroke).toBeUndefined(); }); it('falls back to a fill and a name where the file says nothing usable', () => { const f = drawing(); const art = f.art() as unknown as Record; art.shapes = [{ id: 'one', rings: [{ closed: false, points: [] }], fill: 'blue' }]; const shape = f.art(roundTrip(f.project)).shapes[0]!; expect(shape).toMatchObject({ name: 'Shape', fill: SHAPE_FILL_DEFAULT, opacity: 1 }); expect(shape.rings).toEqual([{ closed: false, points: [] }]); }); });