/** * Gradients and picture fills of vector art (#186): the rules of `vectorPaint`, what the drawing * commands make of a paint, how a project file brings one back, and the commands that write it. */ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, CreateTextureCommand } from '../../src/core/commands/assetCommands'; import { CommandError } from '../../src/core/commands/Command'; import { AddVectorShapeCommand, SetVectorShapeRingsCommand, SetVectorShapeStyleCommand, } from '../../src/core/commands/vectorArtCommands'; import type { TextureAsset, VectorArtAsset, VectorGradient, VectorRing, VectorShape, } from '../../src/core/model/Asset'; import { findAsset, type Project } from '../../src/core/model/Project'; import { vectorArtRevision } from '../../src/core/model/vectorart'; import { colorAt, fillPaintFor, paintExtent, paintMoved, sortedStops, strokePaintFor, textureFillRef, withoutStop, withStopAdded, withStopChanged, } from '../../src/core/model/vectorPaint'; import { shapeDrawing } from '../../src/core/model/vectorPath'; import { makeProject, roundTrip } from '../helpers'; import { allShapes } from '../../src/core/model/vectorTree'; const FILE = `${'c'.repeat(64)}.png`; const SQUARE: VectorRing = { closed: true, points: [ { x: 20, y: 40 }, { x: 120, y: 40 }, { x: 120, y: 90 }, { x: 20, y: 90 }, ], }; function shape(over: Partial = {}): VectorShape { return { id: 's', name: 'Shape 1', rings: [SQUARE], fill: 0x336699, opacity: 1, ...over }; } function gradient(over: Partial = {}): VectorGradient { return { type: 'linear', from: { x: 0, y: 0 }, to: { x: 100, y: 0 }, stops: [ { offset: 0, color: 0x000000, alpha: 1 }, { offset: 1, color: 0xffffff, alpha: 0 }, ], ...over, }; } /** A project with a drawing holding one square, and a 64x32 texture with one 16x16 frame. */ function withTexture() { const fixture = makeProject(); const art = new CreateAssetCommand('vectorart', 'Logo'); fixture.stack.execute(art); const texture = new CreateTextureCommand('Bricks', { file: FILE, width: 64, height: 32 }); fixture.stack.execute(texture); const sheet = findAsset(fixture.project, texture.asset.id) as TextureAsset; sheet.frames.push({ id: 'f1', name: 'Brick', x: 16, y: 0, w: 16, h: 16 }); const add = new AddVectorShapeCommand(art.asset.id, [SQUARE]); fixture.stack.execute(add); const drawing = (project: Project = fixture.project) => findAsset(project, art.asset.id) as VectorArtAsset; return { ...fixture, assetId: art.asset.id, shapeId: add.shapeId, textureId: texture.asset.id, drawing }; } describe('switching a fill to a paint', () => { const extent = paintExtent({ x0: 20, y0: 40, x1: 120, y1: 90 }, { width: 256, height: 256 }); it('lays a linear gradient across the middle of the shape, from its colour to white', () => { expect(fillPaintFor(shape(), 'linear', extent)).toEqual({ type: 'linear', from: { x: 20, y: 65 }, to: { x: 120, y: 65 }, stops: [ { offset: 0, color: 0x336699, alpha: 1 }, { offset: 1, color: 0xffffff, alpha: 1 }, ], }); }); it('centres a radial gradient and lets it reach the farther side', () => { const paint = fillPaintFor(shape(), 'radial', extent); expect(paint).toMatchObject({ type: 'radial', from: { x: 70, y: 65 }, to: { x: 120, y: 65 } }); }); it('keeps the colours when a gradient only changes its kind, and lays it out anew', () => { const linear = gradient({ stops: [...gradient().stops, { offset: 0.3, color: 0xff0000, alpha: 1 }] }); const radial = fillPaintFor(shape({ paint: linear }), 'radial', extent) as VectorGradient; expect(radial.stops).toEqual(linear.stops); expect(radial.from).toEqual({ x: 70, y: 65 }); }); it('spans a picture over the shape once, keeping the picture it had', () => { const tiled = shape({ paint: { type: 'texture', from: { x: 0, y: 0 }, to: { x: 8, y: 8 }, texture: 't' }, }); expect(fillPaintFor(tiled, 'texture', extent)).toEqual({ type: 'texture', from: { x: 20, y: 40 }, to: { x: 120, y: 90 }, texture: 't', }); expect(fillPaintFor(shape(), 'solid', extent)).toBeNull(); }); it('starts an outline gradient from the line colour, and a shape without one gets none', () => { const outlined = shape({ stroke: { color: 0x00ff00, width: 2 } }); expect((strokePaintFor(outlined, 'linear', extent) as VectorGradient).stops[0]?.color).toBe(0x00ff00); expect(strokePaintFor(shape(), 'linear', extent)).toBeNull(); }); it('never lays a paint over nothing: a line has a height, an empty shape the canvas', () => { expect(paintExtent({ x0: 5, y0: 10, x1: 50, y1: 10 }, { width: 64, height: 64 }).y1).toBe(11); expect(paintExtent(null, { width: 64, height: 32 })).toEqual({ x0: 0, y0: 0, x1: 64, y1: 32 }); }); }); describe('colour stops', () => { it('adds a stop in the colour the gradient already has there, so nothing changes yet', () => { const added = withStopAdded(gradient(), 0.25); expect(added.stops).toHaveLength(3); expect(added.stops[2]).toEqual({ offset: 0.25, color: 0x404040, alpha: 0.75 }); }); it('reads the colour between stops in drawing order, whatever order the list is in', () => { const shuffled = [ { offset: 1, color: 0x0000ff, alpha: 1 }, { offset: 0, color: 0xff0000, alpha: 1 }, ]; expect(colorAt(shuffled, 0.5).color).toBe(0x800080); expect(colorAt(shuffled, -3).color).toBe(0xff0000); expect(colorAt(shuffled, 7).color).toBe(0x0000ff); }); it('keeps a dragged stop where it is in the list, and sorts only for drawing', () => { const moved = withStopChanged(gradient(), 0, { offset: 0.9 })!; expect(moved.stops[0]?.offset).toBe(0.9); expect(sortedStops(moved.stops).map((s) => s.offset)).toEqual([0.9, 1]); expect(withStopChanged(gradient(), 0, { offset: 4, alpha: -1 })!.stops[0]).toMatchObject({ offset: 1, alpha: 0, }); expect(withStopChanged(gradient(), 5, { color: 1 })).toBeNull(); }); it('takes a stop out but never the second-last one', () => { const three = withStopAdded(gradient(), 0.5); expect(withoutStop(three, 0)?.stops.map((s) => s.offset)).toEqual([1, 0.5]); expect(withoutStop(gradient(), 0)).toBeNull(); }); it('moves both ends of a paint with its shape', () => { expect(paintMoved(gradient(), 5, -2)).toMatchObject({ from: { x: 5, y: -2 }, to: { x: 105, y: -2 } }); }); }); describe('drawing a paint', () => { it('hands the stops out sorted and keeps the solid colour next to it', () => { const drawn = shapeDrawing(shape({ paint: gradient({ stops: [...gradient().stops].reverse() }) }))!; expect(drawn.color).toBe(0x336699); expect(drawn.paint).toMatchObject({ type: 'linear', stops: [{ offset: 0 }, { offset: 1 }] }); expect(drawn.strokePaint).toBeNull(); }); it('falls back to the colour for a gradient that has no length or one stop', () => { expect(shapeDrawing(shape({ paint: gradient({ to: { x: 0, y: 0 } }) }))!.paint).toBeNull(); const single = gradient({ stops: [{ offset: 0, color: 0, alpha: 1 }] }); expect(shapeDrawing(shape({ paint: single }))!.paint).toBeNull(); }); it('paints the outline with its own gradient', () => { const drawn = shapeDrawing( shape({ stroke: { color: 0, width: 3, paint: gradient({ type: 'radial' }) } }), )!; expect(drawn.strokePaint?.type).toBe('radial'); }); it('resolves a picture fill to the texture or frame it names, never to a drawn picture', () => { const f = withTexture(); const whole = textureFillRef(f.project, f.textureId); expect(whole).toMatchObject({ file: FILE, rect: null }); expect(textureFillRef(f.project, 'f1')).toMatchObject({ file: FILE, rect: { x: 16, y: 0, w: 16, h: 16 }, }); expect(textureFillRef(f.project, f.assetId)).toBeNull(); expect(textureFillRef(f.project, 'gone')).toBeNull(); const paint = { type: 'texture' as const, from: { x: 0, y: 0 }, to: { x: 16, y: 16 }, texture: 'f1' }; expect(shapeDrawing(shape({ paint }), f.project)!.paint).toMatchObject({ type: 'texture', ref: { file: FILE }, }); // A picture that is not there, and a tile of no width, draw the colour instead. expect(shapeDrawing(shape({ paint: { ...paint, texture: 'gone' } }), f.project)!.paint).toBeNull(); expect(shapeDrawing(shape({ paint: { ...paint, to: { x: 0, y: 16 } } }), f.project)!.paint).toBeNull(); }); }); describe('a paint in the picture key and the project file', () => { it('is a new drawing when a stop, an end or the picture behind a texture changes', () => { const f = withTexture(); const shapeOf = () => allShapes(f.drawing())[0]!; const plain = vectorArtRevision(f.drawing(), f.drawing().props, f.project); shapeOf().paint = gradient(); const linear = vectorArtRevision(f.drawing(), f.drawing().props, f.project); expect(linear).not.toBe(plain); shapeOf().paint = withStopChanged(gradient(), 1, { alpha: 0.5 })!; expect(vectorArtRevision(f.drawing(), f.drawing().props, f.project)).not.toBe(linear); shapeOf().paint = { type: 'texture', from: { x: 0, y: 0 }, to: { x: 16, y: 16 }, texture: 'f1' }; const brick = vectorArtRevision(f.drawing(), f.drawing().props, f.project); // The frame is moved on its sheet: same id on the shape, another picture in it. (findAsset(f.project, f.textureId) as TextureAsset).frames[0]!.x = 32; expect(vectorArtRevision(f.drawing(), f.drawing().props, f.project)).not.toBe(brick); }); it('comes back from a project file as it was written', () => { const f = withTexture(); const shapeNow = allShapes(f.drawing())[0]!; shapeNow.paint = gradient({ type: 'radial' }); shapeNow.stroke = { color: 0xff0000, width: 2, paint: gradient() }; const back = allShapes(f.drawing(roundTrip(f.project)))[0]!; expect(back.paint).toEqual(shapeNow.paint); expect(back.stroke).toEqual(shapeNow.stroke); expect(vectorArtRevision(f.drawing(roundTrip(f.project)))).toBe(vectorArtRevision(f.drawing())); }); it('drops a paint it cannot draw and keeps the colour the shape had', () => { const f = withTexture(); const raw = JSON.parse(JSON.stringify(f.project)) as { assets: { type: string; layers?: { items: unknown[] }[] }[]; }; const drawing = raw.assets.find((a) => a.type === 'vectorart')!; const written = drawing.layers![0]!.items[0] as Record; written.paint = { type: 'linear', from: { x: 0, y: 0 }, to: { x: 1, y: 0 }, stops: [{ offset: 0, color: 1 }], }; written.stroke = { color: 5, width: 1, paint: { type: 'texture', from: { x: 0, y: 0 }, to: { x: 1, y: 1 }, texture: 'x' }, }; const back = allShapes(f.drawing(roundTrip(raw as unknown as Project)))[0]!; expect(back.paint).toBeUndefined(); expect(back.stroke).toEqual({ color: 5, width: 1 }); }); }); describe('writing a paint', () => { it('sets and removes a fill paint as one undo step each', () => { const f = withTexture(); f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, f.shapeId, { paint: gradient() })); expect(allShapes(f.drawing())[0]!.paint).toEqual(gradient()); f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, f.shapeId, { paint: null })); expect(allShapes(f.drawing())[0]!.paint).toBeUndefined(); f.stack.undo(); expect(allShapes(f.drawing())[0]!.paint).toEqual(gradient()); f.stack.undo(); expect(allShapes(f.drawing())[0]!.paint).toBeUndefined(); }); it('refuses a gradient of one colour, on the fill and on the outline', () => { const f = withTexture(); const one = gradient({ stops: [{ offset: 0, color: 0, alpha: 1 }] }); expect(() => new SetVectorShapeStyleCommand(f.assetId, f.shapeId, { paint: one }).execute(f.project), ).toThrow(CommandError); const stroke = { color: 0, width: 1, paint: one }; expect(() => new SetVectorShapeStyleCommand(f.assetId, f.shapeId, { stroke }).execute(f.project)).toThrow( CommandError, ); }); it('moves the paints along when a whole shape is dragged, and undo takes both back', () => { const f = withTexture(); const shapeNow = allShapes(f.drawing())[0]!; shapeNow.paint = gradient(); shapeNow.stroke = { color: 0, width: 1, paint: gradient({ type: 'radial' }) }; const rings = [{ ...SQUARE, points: SQUARE.points.map((p) => ({ x: p.x + 10, y: p.y })) }]; const paints = { paint: paintMoved(gradient(), 10, 0), strokePaint: paintMoved(gradient({ type: 'radial' }), 10, 0), }; f.stack.execute(new SetVectorShapeRingsCommand(f.assetId, f.shapeId, rings, 'Move shape', paints)); expect(allShapes(f.drawing())[0]!.paint?.from).toEqual({ x: 10, y: 0 }); expect(allShapes(f.drawing())[0]!.stroke?.paint?.to).toEqual({ x: 110, y: 0 }); f.stack.undo(); expect(allShapes(f.drawing())[0]!.paint?.from).toEqual({ x: 0, y: 0 }); expect(allShapes(f.drawing())[0]!.stroke?.paint?.from).toEqual({ x: 0, y: 0 }); }); });