import { describe, expect, it } from 'vitest'; import type { Asset, VectorArtAsset, VectorRing, VectorShape } from '../../src/core/model/Asset'; import type { PropertyBag } from '../../src/core/model/Properties'; import { ringPath, shapeDrawing, vectorArtCanvas, vectorArtDrawings } from '../../src/core/model/vectorPath'; import { VECTOR_HEIGHT_KEY, VECTOR_WIDTH_KEY } from '../../src/core/model/vectorart'; function ring(points: VectorRing['points'], closed = true): VectorRing { return { points, closed }; } const SQUARE = ring([ { x: 10, y: 10 }, { x: 90, y: 10 }, { x: 90, y: 90 }, { x: 10, y: 90 }, ]); const HOLE = ring([ { x: 30, y: 30 }, { x: 70, y: 30 }, { x: 70, y: 70 }, { x: 30, y: 70 }, ]); function shape(over: Partial = {}): VectorShape { return { id: 's1', name: 'Shape 1', rings: [SQUARE], fill: 0x4488ff, opacity: 1, ...over }; } function art(shapes: VectorShape[], props: PropertyBag = {}): VectorArtAsset { return { id: 'a1', type: 'vectorart', name: 'Logo', props, shapes }; } describe('the path of a ring', () => { it('is a line from point to point while no point carries a handle', () => { expect(ringPath(SQUARE)).toEqual([ { op: 'move', x: 10, y: 10 }, { op: 'line', x: 90, y: 10 }, { op: 'line', x: 90, y: 90 }, { op: 'line', x: 10, y: 90 }, { op: 'close' }, ]); }); it('leaves the way back to the first point to `close` while it is straight', () => { const ops = ringPath(SQUARE); expect(ops.filter((op) => op.op === 'close')).toHaveLength(1); expect(ops[ops.length - 1]).toEqual({ op: 'close' }); }); it('draws the way back itself once one of its two ends is bent', () => { const bent = ring([ { x: 10, y: 10, in: { x: -5, y: 0 } }, { x: 90, y: 10 }, { x: 90, y: 90, out: { x: 0, y: 8 } }, ]); const ops = ringPath(bent); expect(ops[ops.length - 2]).toEqual({ op: 'curve', c1x: 90, c1y: 98, c2x: 5, c2y: 10, x: 10, y: 10, }); expect(ops[ops.length - 1]).toEqual({ op: 'close' }); }); it('reads a handle as an offset from its own point', () => { const ops = ringPath( ring( [ { x: 10, y: 10, out: { x: 20, y: 0 } }, { x: 90, y: 10, in: { x: -20, y: 0 } }, ], false, ), ); expect(ops).toEqual([ { op: 'move', x: 10, y: 10 }, { op: 'curve', c1x: 30, c1y: 10, c2x: 70, c2y: 10, x: 90, y: 10 }, ]); }); it('puts the missing control point on its own end, so a corner may sit next to a round side', () => { const ops = ringPath( ring( [ { x: 0, y: 0 }, { x: 40, y: 0, in: { x: -10, y: -10 } }, ], false, ), ); expect(ops[1]).toEqual({ op: 'curve', c1x: 0, c1y: 0, c2x: 30, c2y: -10, x: 40, y: 0 }); }); it('is nothing for a ring without a point', () => { expect(ringPath(ring([]))).toEqual([]); }); it('stays open where the ring is open', () => { expect( ringPath( ring( [ { x: 0, y: 0 }, { x: 10, y: 0 }, ], false, ), ), ).toEqual([ { op: 'move', x: 0, y: 0 }, { op: 'line', x: 10, y: 0 }, ]); }); }); describe('what one shape draws', () => { it('fills its closed rings and strokes nothing without an outline', () => { const drawing = shapeDrawing(shape()); expect(drawing?.fill).toHaveLength(5); expect(drawing?.stroke).toEqual([]); expect(drawing?.color).toBe(0x4488ff); expect(drawing?.alpha).toBe(1); }); it('carries the hole in the same fill path, so it can cut the ring around it out', () => { const drawing = shapeDrawing(shape({ rings: [SQUARE, HOLE] })); expect(drawing?.fill.filter((op) => op.op === 'move')).toHaveLength(2); expect(drawing?.fill[5]).toEqual({ op: 'move', x: 30, y: 30 }); }); it('never fills an open ring but strokes it', () => { const line = ring( [ { x: 0, y: 0 }, { x: 50, y: 50 }, ], false, ); const drawing = shapeDrawing(shape({ rings: [line], stroke: { color: 0xff0000, width: 3 } })); expect(drawing?.fill).toEqual([]); expect(drawing?.stroke).toHaveLength(2); expect(drawing?.strokeColor).toBe(0xff0000); expect(drawing?.strokeWidth).toBe(3); }); it('strokes every ring, the closed ones included', () => { const drawing = shapeDrawing( shape({ rings: [ SQUARE, ring( [ { x: 0, y: 0 }, { x: 5, y: 5 }, ], false, ), ], stroke: { color: 1, width: 2 }, }), ); expect(drawing?.stroke.filter((op) => op.op === 'move')).toHaveLength(2); }); it('passes over a ring nobody has finished drawing', () => { expect(shapeDrawing(shape({ rings: [ring([{ x: 5, y: 5 }])] }))).toBeNull(); const started = shapeDrawing(shape({ rings: [SQUARE, ring([{ x: 5, y: 5 }])] })); expect(started?.fill.filter((op) => op.op === 'move')).toHaveLength(1); }); it('draws nothing when it is fully transparent and has no outline', () => { expect(shapeDrawing(shape({ opacity: 0 }))).toBeNull(); const outlined = shapeDrawing(shape({ opacity: 0, stroke: { color: 0xffffff, width: 1 } })); expect(outlined?.fill).toEqual([]); expect(outlined?.stroke).toHaveLength(5); }); it('counts an outline of width 0 as no outline', () => { expect(shapeDrawing(shape({ stroke: { color: 0xffffff, width: 0 } }))?.stroke).toEqual([]); }); it('keeps the opacity inside 0..1 and reads a broken one as opaque', () => { expect(shapeDrawing(shape({ opacity: 2 }))?.alpha).toBe(1); expect(shapeDrawing(shape({ opacity: Number.NaN }))?.alpha).toBe(1); expect(shapeDrawing(shape({ opacity: 0.25 }))?.alpha).toBe(0.25); }); it('strokes in the fill colour while no colour of its own is set', () => { expect(shapeDrawing(shape({ stroke: { color: 0x4488ff, width: 1 } }))?.strokeColor).toBe(0x4488ff); }); }); describe('what the asset draws', () => { it('hands its shapes out in drawing order, the last one on top', () => { const drawings = vectorArtDrawings(art([shape(), shape({ id: 's2', rings: [HOLE], fill: 0xff0000 })])); expect(drawings.map((d) => d.id)).toEqual(['s1', 's2']); }); it('leaves out what draws nothing instead of handing out an empty shape', () => { const drawings = vectorArtDrawings(art([shape({ id: 'empty', rings: [] }), shape()])); expect(drawings.map((d) => d.id)).toEqual(['s1']); }); it('is drawn on the canvas of the asset, read from the property bag', () => { const canvas = vectorArtCanvas(art([shape()], { [VECTOR_WIDTH_KEY]: 64, [VECTOR_HEIGHT_KEY]: 32 })); expect(canvas).toMatchObject({ width: 64, height: 32 }); expect(canvas?.drawings).toHaveLength(1); }); it('is an empty canvas while nothing is drawn on it, not nothing', () => { expect(vectorArtCanvas(art([]))).toMatchObject({ width: 256, height: 256, drawings: [] }); }); it('is nothing for an asset of another type', () => { const texture = { id: 't1', type: 'texture', name: 'Sky', props: {} } as unknown as Asset; expect(vectorArtCanvas(texture)).toBeNull(); }); });