/**
* 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);
});
});