/** * The gestures of the four point editors (#288): a double-click puts a point down, a click picks * one, Delete takes the picked one away – and only while one is picked. The rule itself first, then * each editor driven through the presses its view hands it. */ import { CanvasTextMetrics, type FederatedPointerEvent, type Graphics } from 'pixi.js'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { buildAsset, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; import { AddColliderCommand } from '../../src/core/commands/colliderCommands'; import { PlaceAssetCommand } from '../../src/core/commands/sceneObjectCommands'; import type { CurveAsset, SceneAsset, TextureAsset, VectorArtAsset } from '../../src/core/model/Asset'; import { IDENTITY } from '../../src/core/model/matrix'; import { findObject } from '../../src/core/model/hierarchy'; import { createEmptyProject } from '../../src/core/model/Project'; import type { Vec2 } from '../../src/core/model/Properties'; import { COLLIDER_POINTS_KEY, colliderPoints } from '../../src/core/properties/collider'; import { CurveCanvas } from '../../src/editor/curve/CurveCanvas'; import { deleteTakes, pickedIn, pointPress } from '../../src/editor/pointGestures'; import { pointMenuEntries } from '../../src/editor/pointMenu'; import { EditorState, objectSelection, shapeSelection } from '../../src/editor/state'; import { TextureCanvas } from '../../src/editor/texture/TextureCanvas'; import { VectorArtCanvas } from '../../src/editor/vectorart/VectorArtCanvas'; import { PolygonCornerEditor } from '../../src/editor/viewport/polygonCorners'; import type { SceneViewHost } from '../../src/editor/viewport/sceneViewHost'; import { resetRenderLoop } from '../../src/ui/render'; import type { DrawItem } from '../../src/viewport/items'; import { PaintCache } from '../../src/viewport/vectorArtPicture'; import { allShapes } from '../../src/core/model/vectorTree'; /** Presses run on one clock, far enough apart that no two join unless a test asks for a double-click. */ let clock = 0; function pressAt(global: Vec2, options: { double?: boolean; alt?: boolean; ctrl?: boolean } = {}) { clock += options.double ? 100 : 1000; return { timeStamp: clock, button: 0, global, altKey: options.alt ?? false, ctrlKey: options.ctrl ?? false, metaKey: false, shiftKey: false, stopPropagation: () => undefined, } as unknown as FederatedPointerEvent; } const key = (name: string) => ({ key: name }) as KeyboardEvent; /** What a sheet canvas does with a press, a move and a release, past the focus its widget takes. */ interface Pressable { press(asset: A, e: FederatedPointerEvent, local: Vec2): void; move(local: Vec2): void; release(): void; } function gestures(canvas: unknown, asset: A) { const target = canvas as Pressable; const click = (at: Vec2, options?: Parameters[1]) => { target.press(asset, pressAt(at, options), at); target.release(); }; return { click, doubleClick: (at: Vec2) => { click(at); click(at, { double: true }); }, drag: (from: Vec2, to: Vec2) => { target.press(asset, pressAt(from), from); target.move(to); target.release(); }, /** A double-click whose second press is held and dragged away before it lets go. */ doubleClickAndDrag: (at: Vec2, to: Vec2) => { click(at); target.press(asset, pressAt(at, { double: true }), at); target.move(to); target.release(); }, }; } beforeAll(() => { CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never; PaintCache.prototype.gradient = (() => null) as never; }); beforeEach(() => resetRenderLoop()); describe('the rule of a press in a point editor', () => { it('picks a point on any press, and adds only on a double-click', () => { expect(pointPress('point', 1)).toBe('pick'); expect(pointPress('point', 2)).toBe('pick'); expect(pointPress('line', 1)).toBe('clear'); expect(pointPress('line', 2)).toBe('add'); expect(pointPress('line', 3)).toBe('clear'); }); it('adds beside the line only where the editor says so', () => { expect(pointPress('free', 2)).toBe('clear'); expect(pointPress('free', 2, true)).toBe('add'); expect(pointPress('free', 1, true)).toBe('clear'); }); it('gives Delete the picked point, and the selection while none is picked', () => { expect(deleteTakes(true)).toBe('point'); expect(deleteTakes(false)).toBe('selection'); }); it('drops a pick that points past the list', () => { expect(pickedIn(3, 2)).toBe(2); expect(pickedIn(3, 3)).toBe(-1); expect(pickedIn(3, -1)).toBe(-1); }); it('offers Delete point, greyed out for a point that must stay, and the type where there is one', () => { const plain = pointMenuEntries({ removable: false, remove: () => undefined }); expect(plain).toHaveLength(1); expect(plain[0]).toMatchObject({ label: 'Delete point', disabled: true }); const kinds: string[] = []; const typed = pointMenuEntries({ removable: true, remove: () => undefined, kind: 'corner', setKind: (kind) => kinds.push(kind), }); const labels = typed.map((entry) => ('separator' in entry ? '-' : entry.label)); expect(labels).toEqual(['Delete point', '-', 'Straight', 'Corner', 'Smooth', 'Symmetric']); expect(typed[0]).toMatchObject({ disabled: false }); const smooth = typed[4]!; if ('onSelect' in smooth) smooth.onSelect(); expect(kinds).toEqual(['smooth']); }); }); describe('the curve tab', () => { function rig() { const state = new EditorState(); state.loadProject(createEmptyProject('p', 'P'), 1); const curve = buildAsset('curve', 'Ease') as CurveAsset; state.project.assets.push(curve); const canvas = new CurveCanvas(state); canvas.show(curve.id); canvas.resize(400, 400); canvas.redraw(); const at = (t: number, v: number) => canvas.graphToScreen(t, v); return { state, curve, canvas, at, ...gestures(canvas, curve) }; } it('adds a point on a double-click, on free space too, picks it and leaves it where it is', () => { const { state, curve, canvas, at, click, doubleClickAndDrag } = rig(); click(at(0.5, 0.9)); expect(curve.points).toHaveLength(2); expect(canvas.pickedIndex).toBe(-1); doubleClickAndDrag(at(0.5, 0.9), at(0.7, 0.2)); expect(curve.points).toHaveLength(3); expect(curve.points[1]!.x).toBeCloseTo(0.5); expect(curve.points[1]!.y).toBeCloseTo(0.9); expect(canvas.pickedIndex).toBe(1); state.undo(); expect(curve.points).toHaveLength(2); }); it('adds one on the line by a double-click, and none by a click', () => { const { curve, at, click, doubleClick } = rig(); click(at(0.5, 0.5)); expect(curve.points).toHaveLength(2); doubleClick(at(0.5, 0.5)); expect(curve.points).toHaveLength(3); }); it('keeps a point through a double-click on it, and drags it once it is pressed again', () => { const { curve, canvas, at, doubleClick, drag } = rig(); doubleClick(at(0.5, 0.9)); doubleClick(at(0.5, 0.9)); expect(curve.points).toHaveLength(3); drag(at(0.5, 0.9), at(0.5, 0.4)); expect(curve.points[1]!.y).toBeCloseTo(0.4); expect(canvas.pickedIndex).toBe(1); }); it('takes the picked point away on Delete, never an end, and nothing while none is picked', () => { const { state, curve, canvas, at, click, doubleClick } = rig(); doubleClick(at(0.5, 0.9)); expect(canvas.onKeyDown(key('Delete'))).toBe(true); expect(curve.points).toHaveLength(2); expect(canvas.pickedIndex).toBe(-1); state.undo(); expect(curve.points).toHaveLength(3); click(at(0, 0)); expect(canvas.pickedIndex).toBe(0); canvas.onKeyDown(key('Delete')); expect(curve.points).toHaveLength(3); click(at(0.3, 0.9)); expect(canvas.pickedIndex).toBe(-1); expect(canvas.onKeyDown(key('Delete'))).toBe(false); expect(curve.points).toHaveLength(3); }); }); describe('the vector art tab', () => { function rig() { const state = new EditorState(); state.loadProject(createEmptyProject('p', 'P'), 1); const asset = buildAsset('vectorart', 'Drawing') as VectorArtAsset; const square = [ { x: 10, y: 10 }, { x: 50, y: 10 }, { x: 50, y: 50 }, { x: 10, y: 50 }, ]; asset.layers[0]!.items.push({ id: 's', name: 'Square', fill: 0x0000ff, opacity: 1, rings: [{ closed: true, points: square }], }); state.project.assets.push(asset); const canvas = new VectorArtCanvas(state); canvas.show(asset.id); canvas.resize(400, 400); canvas.redraw(); state.select(shapeSelection(asset.id, ['s'])); const at = (x: number, y: number) => canvas.imageToScreen(x, y); const points = () => allShapes(asset)[0]?.rings[0]?.points ?? []; return { state, asset, canvas, at, points, ...gestures(canvas, asset) }; } it('adds a point on a double-click on a line, picks it and leaves it where it is', () => { const { state, canvas, at, points, click, doubleClickAndDrag } = rig(); click(at(30, 10)); expect(points()).toHaveLength(4); expect(canvas.pickedKind).toBe(null); doubleClickAndDrag(at(30, 10), at(30, 0)); expect(points()).toHaveLength(5); expect(points()[1]).toMatchObject({ x: 30, y: 10 }); expect(canvas.pickedKind).toBe('straight'); state.undo(); expect(points()).toHaveLength(4); }); it('carries the shape when its line is dragged', () => { const { at, points, drag } = rig(); drag(at(30, 10), at(40, 30)); expect(points()).toHaveLength(4); expect(points()[0]).toMatchObject({ x: 20, y: 30 }); }); it('keeps a point through a double-click on it', () => { const { at, points, doubleClick } = rig(); doubleClick(at(50, 10)); expect(points()).toHaveLength(4); }); it('takes the picked point away on Delete, and the shape while none is picked', () => { const { state, asset, canvas, at, points, click } = rig(); click(at(50, 10)); expect(canvas.pickedKind).toBe('straight'); expect(canvas.onKeyDown(key('Delete'))).toBe(true); expect(points()).toHaveLength(3); expect(allShapes(asset)).toHaveLength(1); state.undo(); expect(points()).toHaveLength(4); click(at(30, 30)); expect(canvas.pickedKind).toBe(null); expect(canvas.onKeyDown(key('Delete'))).toBe(true); expect(allShapes(asset)).toHaveLength(0); }); }); describe('a polygon collider in the scene view', () => { const SQUARE = [ { x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }, { x: 0, y: 100 }, ]; function rig(corners: readonly Vec2[] = SQUARE) { const state = new EditorState(); state.loadProject(createEmptyProject('p', 'P'), 1); const scene = buildAsset('scene', 'Scene') as SceneAsset; const sprite = buildAsset('sprite', 'Crate'); state.project.assets.push(scene, sprite); state.project.activeSceneId = scene.id; const placed = new PlaceAssetCommand(scene.id, sprite.id, { x: 0, y: 0 }); state.execute(placed); const collider = new AddColliderCommand(scene.id, placed.objectId); state.execute(collider); state.execute(new SetAssetPropertyCommand(collider.assetId, 'shape', 'polygon')); state.execute( new SetAssetPropertyCommand( collider.assetId, COLLIDER_POINTS_KEY, corners.map((p) => ({ ...p })), ), ); const colliderAsset = state.project.assets.find((a) => a.id === collider.assetId)!; const props = () => ({ ...colliderAsset.props, ...findObject(scene, collider.objectId)?.overrides, }); const item = () => ({ id: collider.objectId, type: 'collider', m: IDENTITY, props: props() }) as DrawItem; const host: SceneViewHost = { state, holder: () => scene, items: () => [item()], worldAt: (global) => global, local: (global) => global, view: () => ({ x: 0, y: 0, scale: 1, rect: { x: 0, y: 0, w: 0, h: 0 } }), scale: () => 1, redraw: () => undefined, }; const editor = new PolygonCornerEditor(host); const select = () => state.select(objectSelection(scene.id, [collider.objectId])); select(); const points = () => colliderPoints(props()); const press = (at: Vec2, double = false) => { const taken = editor.press(pressAt(at, { double })); editor.release(); return taken; }; return { state, scene, editor, points, press, select, objectId: collider.objectId }; } it('adds a corner on a double-click on an edge, picks it and leaves it where it is', () => { const { state, editor, points, press } = rig(); expect(press({ x: 50, y: 0 })).toBe(true); expect(points()).toHaveLength(4); expect(editor.pickedIndex()).toBe(-1); expect(editor.press(pressAt({ x: 50, y: 0 }, { double: true }))).toBe(true); expect(editor.active).toBe(false); editor.move({ x: 50, y: -40 }); editor.release(); expect(points()).toHaveLength(5); expect(points()[1]).toEqual({ x: 50, y: 0 }); expect(editor.pickedIndex()).toBe(1); state.undo(); expect(points()).toHaveLength(4); }); it('picks a corner on a click, keeps it through a double-click and drags it', () => { const { editor, points, press } = rig(); press({ x: 100, y: 0 }); press({ x: 100, y: 0 }, true); expect(points()).toHaveLength(4); expect(editor.pickedIndex()).toBe(1); editor.press(pressAt({ x: 100, y: 0 })); editor.move({ x: 140, y: 0 }); editor.release(); expect(points()[1]).toEqual({ x: 140, y: 0 }); }); it('takes the picked corner away on Delete and leaves the collider; without a pick it takes nothing', () => { const { state, scene, editor, points, press, objectId } = rig(); expect(editor.removePicked()).toBe(false); press({ x: 100, y: 0 }); expect(editor.removePicked()).toBe(true); expect(points()).toHaveLength(3); expect(findObject(scene, objectId)).toBeDefined(); expect(editor.pickedIndex()).toBe(-1); expect(editor.removePicked()).toBe(false); state.undo(); expect(points()).toHaveLength(4); }); it('keeps the last three corners', () => { const { editor, points, press } = rig(SQUARE.slice(0, 3)); press({ x: 100, y: 0 }); expect(editor.removePicked()).toBe(true); expect(points()).toHaveLength(3); }); it('drops the pick on a press beside the polygon and when the collider is selected anew', () => { const { state, editor, press, select } = rig(); press({ x: 100, y: 0 }); expect(press({ x: 300, y: 300 })).toBe(false); expect(editor.pickedIndex()).toBe(-1); press({ x: 100, y: 0 }); state.select(null); select(); expect(editor.pickedIndex()).toBe(-1); }); it('draws the picked corner', () => { const { editor, press } = rig(); press({ x: 100, y: 100 }); const calls: string[] = []; const g = new Proxy( {}, { get: (_, name: string) => name === 'toGlobal' ? (p: Vec2) => p : (...args: unknown[]) => (calls.push(`${name}:${JSON.stringify(args[0])}`), g), }, ) as unknown as Graphics; editor.draw(g, { x: 0, y: 0, scale: 1, rect: { x: 0, y: 0, w: 0, h: 0 } }); expect(editor.info()?.picked).toBe(2); const fills = calls.filter((call) => call.startsWith('fill:')); expect(new Set(fills).size).toBe(2); expect(fills.filter((fill) => fill === fills[2])).toHaveLength(1); }); }); describe('a collision outline in the texture tab', () => { const TRIANGLE = [ { x: 0, y: 32 }, { x: 32, y: 0 }, { x: 32, y: 32 }, ]; function rig() { const state = new EditorState(); state.loadProject(createEmptyProject('p', 'P'), 1); const texture = buildAsset('texture', 'Sheet') as TextureAsset; texture.width = 64; texture.height = 64; texture.frames.push({ id: 'f', name: 'Wedge', x: 0, y: 0, w: 64, h: 64, collision: [TRIANGLE.map((p) => ({ ...p }))], }); state.project.assets.push(texture); const canvas = new TextureCanvas(state); canvas.show(texture.id); canvas.resize(400, 400); canvas.restoreView({ view: { panX: 0, panY: 0, zoom: 4 }, needsFit: false }); canvas.setCollisionMode(true); state.select({ kind: 'frame', assetId: texture.id, frameId: 'f' }); const at = (x: number, y: number) => ({ x: x * 4, y: y * 4 }); const outlines = () => texture.frames[0]?.collision ?? []; return { state, texture, canvas, at, outlines, ...gestures(canvas, texture) }; } it('adds a corner on a double-click on an edge, picks it and leaves it where it is', () => { const { state, canvas, at, outlines, click, doubleClickAndDrag } = rig(); click(at(32, 16)); expect(outlines()[0]).toHaveLength(3); expect(canvas.pickedCorner).toBe(null); doubleClickAndDrag(at(32, 16), at(50, 16)); expect(outlines()[0]).toHaveLength(4); expect(outlines()[0]![2]).toEqual({ x: 32, y: 16 }); expect(canvas.pickedCorner).toEqual({ outline: 0, index: 2 }); state.undo(); expect(outlines()[0]).toHaveLength(3); }); it('keeps a corner through a double-click on it', () => { const { at, outlines, doubleClick } = rig(); doubleClick(at(32, 0)); expect(outlines()[0]).toHaveLength(3); }); it('takes the picked corner away on Delete, and the whole outline while none is picked', () => { const { state, texture, canvas, at, outlines, click } = rig(); click(at(32, 0)); expect(canvas.pickedCorner).toEqual({ outline: 0, index: 1 }); expect(canvas.onKeyDown(key('Delete'))).toBe(true); expect(outlines()[0]).toHaveLength(2); state.undo(); expect(outlines()[0]).toHaveLength(3); click(at(32, 16)); expect(canvas.pickedCorner).toBe(null); expect(canvas.onKeyDown(key('Delete'))).toBe(true); expect(outlines()).toHaveLength(0); expect(texture.frames).toHaveLength(1); }); it('still draws an outline corner by corner, the last one picked', () => { const { canvas, at, outlines, click } = rig(); click(at(50, 50)); expect(outlines()[0]).toHaveLength(4); expect(canvas.pickedCorner).toEqual({ outline: 0, index: 3 }); canvas.onKeyDown(key('Delete')); expect(outlines()[0]).toHaveLength(3); }); });