/** * Scaling, turning and mirroring vector art shapes (#291): what a ring, a paint and a whole * selection read after an affine map, how far a selection reaches, and the maps a frame handle makes. */ import { describe, expect, it } from 'vitest'; import { buildAsset } from '../../src/core/commands/assetCommands'; import { transformShapesCommand } from '../../src/core/commands/vectorArtCommands'; import type { VectorArtAsset, VectorGradient, VectorRing, VectorShape, VectorTextureFill, } from '../../src/core/model/Asset'; import { translation } from '../../src/core/model/matrix'; import { createEmptyProject } from '../../src/core/model/Project'; import { frameFlipped, frameMovedTo, frameScale, frameSizedTo, geometryOf, geometryTransformed, outlineExtent, paintTransformed, ringTransformed, rotationAbout, scaleAbout, } from '../../src/core/model/vectorTransform'; import { allShapes } from '../../src/core/model/vectorTree'; const BOX = { x0: 10, y0: 20, x1: 50, y1: 40 }; function square(id: string, x: number, y: number, size = 20): VectorShape { return { id, name: id, fill: 0x336699, opacity: 1, rings: [ { closed: true, points: [ { x, y }, { x: x + size, y }, { x: x + size, y: y + size }, { x, y: y + size }, ], }, ], }; } /** A circle of radius `r` round `cx`, `cy`, drawn as four symmetric points. */ function circle(cx: number, cy: number, r: number): VectorRing { // To a thousandth, which is what a map keeps: mirrored twice the circle must read the same. const k = Math.round(r * 552.2847498) / 1000; return { closed: true, points: [ { x: cx + r, y: cy, in: { x: 0, y: -k }, out: { x: 0, y: k }, mode: 'symmetric' }, { x: cx, y: cy + r, in: { x: k, y: 0 }, out: { x: -k, y: 0 }, mode: 'symmetric' }, { x: cx - r, y: cy, in: { x: 0, y: k }, out: { x: 0, y: -k }, mode: 'symmetric' }, { x: cx, y: cy - r, in: { x: -k, y: 0 }, out: { x: k, y: 0 }, mode: 'symmetric' }, ], }; } const LINEAR: VectorGradient = { type: 'linear', from: { x: 10, y: 30 }, to: { x: 50, y: 30 }, stops: [ { offset: 0, color: 0xff0000, alpha: 1 }, { offset: 1, color: 0x0000ff, alpha: 1 }, ], }; const TILE: VectorTextureFill = { type: 'texture', from: { x: 10, y: 20 }, to: { x: 50, y: 40 }, texture: 't', }; describe('a ring under a map', () => { it('scales points about the anchor and handles by the factors alone', () => { const ring: VectorRing = { closed: false, points: [ { x: 10, y: 20, out: { x: 6, y: 4 } }, { x: 50, y: 40, in: { x: -6, y: -2 }, mode: 'smooth' }, ], }; const scaled = ringTransformed(ring, scaleAbout({ x: 10, y: 20 }, 2, 0.5)); expect(scaled.points).toEqual([ { x: 10, y: 20, out: { x: 12, y: 2 } }, { x: 90, y: 30, in: { x: -12, y: -1 }, mode: 'smooth' }, ]); // The ring that went in is not the one that came out. expect(ring.points[1]).toMatchObject({ x: 50, y: 40 }); }); it('turns by a quarter exactly, points and handles, and four quarters are the ring it was', () => { const ring: VectorRing = { closed: true, points: [ { x: 10, y: 20, out: { x: 5, y: 0 } }, { x: 50, y: 20 }, { x: 50, y: 40, in: { x: 0, y: -3 }, mode: 'smooth', out: { x: 0, y: 3 } }, ], }; const centre = { x: 30, y: 30 }; const quarter = ringTransformed(ring, rotationAbout(centre, 90)); // Clockwise on screen: the top left corner goes to the top right. expect(quarter.points[0]).toEqual({ x: 40, y: 10, out: { x: 0, y: 5 } }); expect(quarter.points[1]).toEqual({ x: 40, y: 50 }); expect(quarter.points[2]).toEqual({ x: 20, y: 50, in: { x: 3, y: 0 }, out: { x: -3, y: 0 }, mode: 'smooth', }); let round = ring; for (let i = 0; i < 4; i++) round = ringTransformed(round, rotationAbout(centre, 90)); expect(round).toEqual(ring); expect(ringTransformed(ring, rotationAbout(centre, -270))).toEqual(quarter); }); it('keeps a turn by any other angle to a thousandth of a pixel', () => { const ring: VectorRing = { closed: false, points: [ { x: 10, y: 0 }, { x: 0, y: 0 }, ], }; const turned = ringTransformed(ring, rotationAbout({ x: 0, y: 0 }, 30)); expect(turned.points[0]).toEqual({ x: 8.66, y: 5 }); expect(Object.is(turned.points[1]!.x, 0)).toBe(true); }); it('mirrors about the middle and keeps a symmetric point symmetric', () => { const ring = circle(30, 30, 10); const flipped = ringTransformed( ring, frameFlipped(outlineExtent([{ ...square('c', 0, 0), rings: [ring] }])!, 'x'), ); expect(flipped.points[0]).toMatchObject({ x: 20, y: 30, mode: 'symmetric' }); const { in: a, out: b } = flipped.points[1]!; expect(a!.x).toBeCloseTo(-b!.x, 9); expect(a!.y).toBeCloseTo(-b!.y, 9); // Mirrored twice it is the ring it was. const box = outlineExtent([{ ...square('c', 0, 0), rings: [flipped] }])!; expect(ringTransformed(flipped, frameFlipped(box, 'x'))).toEqual(ring); }); it('drops a handle a scale squeezes to nothing, and the mode with the last one', () => { const ring: VectorRing = { closed: false, points: [ { x: 0, y: 0, out: { x: 0, y: 8 }, mode: 'smooth' }, { x: 10, y: 0 }, ], }; const flat = ringTransformed(ring, scaleAbout({ x: 0, y: 0 }, 1, 0)); expect(flat.points[0]).toEqual({ x: 0, y: 0 }); }); }); describe('a paint under a map', () => { it('takes both ends of a gradient along and leaves the stops alone', () => { const moved = paintTransformed(LINEAR, rotationAbout({ x: 30, y: 30 }, 90)); expect(moved.from).toEqual({ x: 30, y: 10 }); expect(moved.to).toEqual({ x: 30, y: 50 }); expect(moved.stops).toEqual(LINEAR.stops); expect(moved.stops).not.toBe(LINEAR.stops); expect(paintTransformed(LINEAR, translation(5, -5))).toMatchObject({ from: { x: 15, y: 25 }, to: { x: 55, y: 25 }, }); }); it('scales and mirrors a picture tile exactly', () => { expect(paintTransformed(TILE, scaleAbout({ x: 10, y: 20 }, 2, 3))).toMatchObject({ from: { x: 10, y: 20 }, to: { x: 90, y: 80 }, texture: 't', }); // Mirrored, the tile's ends swap sides: the picture is drawn the other way round. expect(paintTransformed(TILE, scaleAbout({ x: 30, y: 30 }, -1, 1))).toMatchObject({ from: { x: 50, y: 20 }, to: { x: 10, y: 40 }, }); }); it('keeps a turned picture tile upright, over the room the turned tile reaches', () => { const quarter = paintTransformed(TILE, rotationAbout({ x: 30, y: 30 }, 90)); expect(quarter).toMatchObject({ from: { x: 20, y: 10 }, to: { x: 40, y: 50 } }); const eighth = paintTransformed(TILE, rotationAbout({ x: 30, y: 30 }, 45)); const half = (20 + 10) * Math.SQRT1_2; expect(eighth.from.x).toBeCloseTo(30 - half, 2); expect(eighth.to.y).toBeCloseTo(30 + half, 2); }); }); describe('how far a selection reaches', () => { it('spans all the shapes handed in', () => { expect(outlineExtent([square('a', 0, 0), square('b', 40, 10)])).toEqual({ x0: 0, y0: 0, x1: 60, y1: 30 }); }); it('touches a circle where it is drawn, not where its handles stand out', () => { const e = outlineExtent([{ ...square('c', 0, 0), rings: [circle(30, 30, 10)] }])!; expect(e.x0).toBeCloseTo(20, 6); expect(e.x1).toBeCloseTo(40, 6); expect(e.y0).toBeCloseTo(20, 6); expect(e.y1).toBeCloseTo(40, 6); }); it('follows a curve that bulges past its own two ends', () => { const ring: VectorRing = { closed: false, points: [ { x: 0, y: 0, out: { x: 0, y: -40 } }, { x: 30, y: 0, in: { x: 0, y: -40 } }, ], }; const e = outlineExtent([{ ...square('c', 0, 0), rings: [ring] }])!; // Both control points stand 40 above; the curve between them reaches three quarters of that. expect(e.y0).toBeCloseTo(-30, 6); expect(e.y1).toBe(0); }); it('does not walk back from the last point of an open line', () => { const ring: VectorRing = { closed: false, points: [ { x: 0, y: 0, in: { x: 0, y: 50 } }, { x: 30, y: 0, out: { x: 0, y: 50 } }, ], }; expect(outlineExtent([{ ...square('c', 0, 0), rings: [ring] }])).toEqual({ x0: 0, y0: 0, x1: 30, y1: 0 }); }); it('has nothing to frame for no shape, an empty one and a single point', () => { expect(outlineExtent([])).toBeNull(); expect(outlineExtent([{ ...square('a', 0, 0), rings: [] }])).toBeNull(); expect( outlineExtent([{ ...square('a', 0, 0), rings: [{ closed: false, points: [{ x: 4, y: 4 }] }] }]), ).toBeNull(); }); }); describe('the map of a handle', () => { const corner = { sx: 1, sy: 1 } as const; it('scales from the opposite corner when a corner is pulled', () => { expect(frameScale(BOX, corner, { x: 90, y: 50 })).toEqual(scaleAbout({ x: 10, y: 20 }, 2, 1.5)); expect(frameScale(BOX, { sx: -1, sy: -1 }, { x: -10, y: 10 })).toEqual( scaleAbout({ x: 50, y: 40 }, 1.5, 1.5), ); }); it('scales one direction only from an edge', () => { expect(frameScale(BOX, { sx: 1, sy: 0 }, { x: 30, y: 999 })).toEqual( scaleAbout({ x: 10, y: 30 }, 0.5, 1), ); expect(frameScale(BOX, { sx: 0, sy: -1 }, { x: 999, y: 0 })).toEqual(scaleAbout({ x: 30, y: 40 }, 1, 2)); }); it('keeps the proportions with Shift: a corner follows its farther axis, an edge takes the other along', () => { expect(frameScale(BOX, corner, { x: 90, y: 50 }, { proportional: true })).toEqual( scaleAbout({ x: 10, y: 20 }, 2, 2), ); // The edge pulled out to double width doubles the height about the middle line. expect(frameScale(BOX, { sx: 1, sy: 0 }, { x: 90, y: 0 }, { proportional: true })).toEqual( scaleAbout({ x: 10, y: 30 }, 2, 2), ); }); it('scales about the middle with Alt', () => { expect(frameScale(BOX, corner, { x: 70, y: 40 }, { centred: true })).toEqual( scaleAbout({ x: 30, y: 30 }, 2, 1), ); }); it('never pulls a side through its opposite: the frame stays a pixel wide', () => { const m = frameScale(BOX, corner, { x: -100, y: -100 }); expect(m.a).toBeCloseTo(1 / 40, 9); expect(m.d).toBeCloseTo(1 / 20, 9); }); it('leaves a direction alone in which the frame has no extent', () => { const line = { x0: 0, y0: 5, x1: 40, y1: 5 }; expect(frameScale(line, corner, { x: 80, y: 60 })).toEqual(scaleAbout({ x: 0, y: 5 }, 2, 1)); }); it('moves, sizes and mirrors the frame by a number', () => { expect(frameMovedTo(BOX, 'x', 0)).toEqual(translation(-10, 0)); expect(frameMovedTo(BOX, 'y', 25)).toEqual(translation(0, 5)); expect(frameSizedTo(BOX, 'x', 80)).toEqual(scaleAbout({ x: 10, y: 20 }, 2, 1)); expect(frameSizedTo(BOX, 'y', 0)).toEqual(scaleAbout({ x: 10, y: 20 }, 1, 1 / 20)); expect(frameSizedTo({ ...BOX, x1: 10 }, 'x', 80)).toBeNull(); expect(frameFlipped(BOX, 'y')).toEqual(scaleAbout({ x: 30, y: 30 }, 1, -1)); }); }); describe('the command', () => { function rig() { const project = createEmptyProject('p', 'P'); const asset = buildAsset('vectorart', 'Drawing') as VectorArtAsset; const a = square('a', 0, 0); const b: VectorShape = { ...square('b', 40, 0), paint: { ...LINEAR }, stroke: { color: 0, width: 3 } }; const c = square('c', 80, 0); asset.layers[0]!.items.push(a, { id: 'g', name: 'Group', items: [b] }, c); asset.layers.push({ id: 'upper', name: 'Layer 2', items: [square('d', 0, 40)] }); project.assets.push(asset); const corner = (id: string) => allShapes(asset).find((s) => s.id === id)!.rings[0]!.points[0]; return { project, asset, corner }; } it('takes several shapes, one in a group and one on another layer, in one step and back', () => { const r = rig(); const command = transformShapesCommand( r.asset, ['a', 'b', 'd'], scaleAbout({ x: 0, y: 0 }, 2, 2), 'Scale shapes', )!; command.execute(r.project); expect(r.corner('a')).toEqual({ x: 0, y: 0 }); expect(allShapes(r.asset).find((s) => s.id === 'a')!.rings[0]!.points[2]).toEqual({ x: 40, y: 40 }); expect(r.corner('b')).toEqual({ x: 80, y: 0 }); expect(r.corner('d')).toEqual({ x: 0, y: 80 }); expect(r.corner('c')).toEqual({ x: 80, y: 0 }); command.undo(r.project); expect(r.corner('b')).toEqual({ x: 40, y: 0 }); expect(r.corner('d')).toEqual({ x: 0, y: 40 }); }); it('takes the fill paint along and leaves the line as wide as it was', () => { const r = rig(); const b = allShapes(r.asset).find((s) => s.id === 'b')!; const command = transformShapesCommand(r.asset, ['b'], scaleAbout({ x: 0, y: 0 }, 3, 3), 'Scale shape')!; command.execute(r.project); expect(b.paint).toMatchObject({ from: { x: 30, y: 90 }, to: { x: 150, y: 90 } }); expect(b.stroke).toEqual({ color: 0, width: 3 }); command.undo(r.project); expect(b.paint).toMatchObject({ from: { x: 10, y: 30 }, to: { x: 50, y: 30 } }); }); it('takes the gradient of the line along as well', () => { const r = rig(); const b = allShapes(r.asset).find((s) => s.id === 'b')!; b.stroke = { color: 0, width: 3, paint: { ...LINEAR } }; transformShapesCommand(r.asset, ['b'], translation(5, 7), 'Move shape')!.execute(r.project); expect(b.stroke).toMatchObject({ width: 3, paint: { from: { x: 15, y: 37 }, to: { x: 55, y: 37 } } }); }); it('is no step at all when nothing would read differently', () => { const r = rig(); expect( transformShapesCommand(r.asset, ['a'], rotationAbout({ x: 10, y: 10 }, 360), 'Rotate shape'), ).toBeNull(); expect(transformShapesCommand(r.asset, ['nobody'], translation(5, 5), 'Move shape')).toBeNull(); }); it('hands the rings on untouched when a geometry is read, and copies the paints', () => { const r = rig(); const b = allShapes(r.asset).find((s) => s.id === 'b')!; const geometry = geometryOf(b); expect(geometry.rings).toBe(b.rings); expect(geometry.paint).toEqual(b.paint); expect(geometry.paint).not.toBe(b.paint); expect(geometryTransformed(geometry, translation(1, 1)).rings).not.toBe(b.rings); }); });