Files
feuerwerk/tests/editor/point-gestures.test.ts
T
adminandClaude Opus 5.5 0f85476b93
game-maker / gate (push) Successful in 2m39s
game-maker / suite (push) Successful in 13m8s
Add points by double-click and delete the picked one (#288)
Four editors edit points, and all of them put a point down on a single
press on the line and took one away on a double-click. A stray click
added a point, and there was no way to see which point a key would hit.
All four now work the same way: the Curve tab, the Vector art tab, a
polygon collider in the scene view and a frame's collision outline in
the Texture tab.

- A double-click on a line, edge or segment puts a point there, in the
  Curve tab on free space too. The point is picked and stays in place.
  It can be dragged with the next press, as answered in the issue.
- A click on a point picks it, and it is drawn filled. A double-click
  on a point takes nothing away any more.
- Delete or Backspace takes the picked point away. Without a picked
  point the key removes the shape, the object or the outline as before.
- A right-click on a point picks it and opens a menu with Delete
  point. In the Curve and Vector art tab the four point types stand
  below it. Alt-click stays.
- A polygon keeps three corners and a curve keeps its ends. The entry
  is greyed out then.
- The pick ends with a click beside the points, with another object,
  shape or frame, and after the point is gone.
- editor/pointGestures.ts holds the rule as a pure function, and
  editor/pointMenu.ts builds the menu for all four.

Decided here:

- A single click on a line no longer adds anything. In Vector art it
  drags the shape, on a polygon it drops the pick, and in the Texture
  tab it makes that outline the active one and drops the pick.
- In the Texture tab a click inside the frame still appends a corner,
  and that corner is picked. Delete takes the last corner first. To
  remove the whole outline, click one of its edges before.
- The point the Pen put down last is picked, so Delete takes it and
  not the shape.
- Delete hits the picked point also when the tab has lost the keyboard
  focus, after a click in the shape list or after the menu.
- A vector shape that loses its last point is removed whole. Before,
  an empty shape stayed in the list.

Findings:

- Fixed: the new hint line in the Texture tab was cut off at the edge.
  The shorter text was not looked at in a picture again.
- Not fixed: in the scene view the collider's name label covers a
  corner that sits near the centre. That was so before.

Tests:
- Gate: 200 files, 2967 tests (2944 + 23), exit 0
- e2e, full run: 1629 checks passed (1625 + 4), exit 0
- Smokes: 24 / 19 / 21, exit 0
- After the full run an unused parameter and a comment were tidied.
  For that final state the gate and e2e 42,47,53 (105 checks) ran
  again, the full run did not.
- 23 unit tests in tests/editor/point-gestures.test.ts, for the rule
  and for each of the four editors
- Four e2e checks with a real mouse, two in module 42 and one each in
  47 and 53. The existing checks for adding and removing a point use
  the new gestures.
- 20 unit counter-probes all fail tests, run on stein2
- Two browser counter-probes fail, the build went through both times.
  Without the Delete wiring the key removes the collider. Without the
  right-click hook the menu is missing, and that run stops in module
  42, so 47 and 53 were not reached with it.
- Three screenshots looked at, the picked point is filled. None of
  the Vector art tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:36:13 +02:00

482 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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';
/** 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<A> {
press(asset: A, e: FederatedPointerEvent, local: Vec2): void;
move(local: Vec2): void;
release(): void;
}
function gestures<A>(canvas: unknown, asset: A) {
const target = canvas as Pressable<A>;
const click = (at: Vec2, options?: Parameters<typeof pressAt>[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 (!('separator' 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.shapes.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 = () => asset.shapes[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(asset.shapes).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(asset.shapes).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);
});
});