Add points by double-click and delete the picked one (#288)
game-maker / gate (push) Successful in 2m39s
game-maker / suite (push) Successful in 13m8s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-01 19:36:13 +02:00
1 parent 1aa824ecb4
commit 0f85476b93
19 files changed
+1180 -168

No files matched your search

+86 -12
View File
@@ -87,7 +87,8 @@ export async function shapes(t) {
};`;
// 42a. A polygon collider on a crate, selected: a handle on each corner. A corner is dragged, a
// press on an edge puts a corner there, a double-click takes it away – one undo step each.
// double-click on an edge puts a corner there, Delete or the corner's menu takes the picked
// one away (#288) – one undo step each.
await mutate(`
const scene = __gm.state.activeScene;
const assets = __gm.state.project.assets;
@@ -120,21 +121,56 @@ export async function shapes(t) {
(await undo(), (await points()) === undefined),
JSON.stringify({ dragged, zoom }),
);
const pickedCorner = async () => (await json('__gm.polygonCorners()'))?.picked ?? null;
const [a, b] = await corners();
await click((a.x + b.x) / 2, (a.y + b.y) / 2);
const onEdge = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
await click(onEdge.x, onEdge.y);
const clicked = await points();
// Past the double-click window: the click above must not count as the first of the pair.
await sleep(500);
await doubleClick(onEdge.x, onEdge.y);
const inserted = await points();
check(
'a press on an edge puts a corner there',
inserted?.length === 4 && Math.abs(inserted[1].x - 40) <= 1 && Math.abs(inserted[1].y) <= 1,
JSON.stringify(inserted),
'a click on an edge adds nothing, a double-click puts a corner there and picks it',
clicked === undefined &&
inserted?.length === 4 &&
Math.abs(inserted[1].x - 40) <= 1 &&
Math.abs(inserted[1].y) <= 1 &&
(await pickedCorner()) === 1,
JSON.stringify({ clicked, inserted, picked: await pickedCorner() }),
);
await shot('42-polygon-corners');
const fourth = (await corners())[1];
await sleep(500);
await doubleClick(fourth.x, fourth.y);
const kept = await points();
await press('Delete');
const deleted = await points();
check(
'a double-click on a corner takes it away again',
JSON.stringify(await points()) === JSON.stringify(TRIANGLE),
JSON.stringify(await points()),
'a double-click on a corner keeps it; Delete takes the picked corner away and leaves the collider',
kept?.length === 4 &&
JSON.stringify(deleted) === JSON.stringify(TRIANGLE) &&
Boolean(await object('sh-poly-col')) &&
(await pickedCorner()) === -1 &&
(await undo(), (await points())?.length === 4),
JSON.stringify({ kept, deleted, picked: await pickedCorner() }),
);
const again = (await corners())[1];
await click(again.x, again.y, 'right');
const cornerMenu = await json('__gm.menuRows()');
await clickText('Delete point');
const byMenu = await points();
const last = (await corners())[1];
await click(last.x, last.y, 'right');
const lastThree = await json('__gm.menuEnabled()');
await press('Escape');
check(
"a right-click on a corner offers Delete point, which takes it away – greyed out at the polygon's last three",
JSON.stringify(cornerMenu) === JSON.stringify(['Delete point']) &&
JSON.stringify(byMenu) === JSON.stringify(TRIANGLE) &&
JSON.stringify(lastThree) === JSON.stringify([false]) &&
Boolean(await object('sh-poly-col')),
JSON.stringify({ cornerMenu, byMenu, lastThree }),
);
const clearShapes = () =>
mutate(`
@@ -269,8 +305,9 @@ export async function shapes(t) {
moved?.[0]?.[1]?.x === 16 && moved[0][1].y === 0 && (await outline())?.[0]?.[1]?.x === 32,
JSON.stringify({ moved, after: await outline() }),
);
// A frame holds several outlines: Ctrl+click starts a second one, and Delete takes the active
// outline away whole – not the frame, which Delete removes outside the mode.
// A frame holds several outlines: Ctrl+click starts a second one. The corner put down last is the
// picked one; a click on an edge drops the pick, and Delete then takes the active outline away
// whole – not the frame, which Delete removes outside the mode.
for (const [i, [x, y]] of [
[2, 2],
[10, 2],
@@ -280,12 +317,18 @@ export async function shapes(t) {
await click(at.x, at.y, 'left', i === 0 ? { ctrl: true } : {});
}
const two = await outline();
const pickedLast = await json('__gm.texture.pickedCorner()');
await shot('42-texture-two-outlines');
const secondEdge = await json('__gm.texture.pointAt(6, 2)');
await click(secondEdge.x, secondEdge.y);
const pickedNone = await json('__gm.texture.pickedCorner()');
await press('Delete');
const left = await outline();
check(
'Ctrl+click starts a second outline in the frame, and Delete removes only that one',
two?.length === 2 &&
JSON.stringify(pickedLast) === JSON.stringify({ outline: 1, index: 2 }) &&
pickedNone === null &&
two?.length === 2 &&
JSON.stringify(two[1]) ===
JSON.stringify([
{ x: 2, y: 2 },
@@ -297,7 +340,38 @@ export async function shapes(t) {
(await json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture?.id)}).frames.some((f) => f.id === 'sh-frame')`,
)) === true,
JSON.stringify({ two, left }),
JSON.stringify({ two, left, pickedLast, pickedNone }),
);
// #288 on the outline that is left: a double-click on an edge puts a corner there and picks it,
// Delete takes the picked corner (not the outline), and a corner's menu does the same.
const slope = await json('__gm.texture.pointAt(16, 16)');
await sleep(500);
await doubleClick(slope.x, slope.y);
const four = await outline();
const pickedNew = await json('__gm.texture.pickedCorner()');
await press('Delete');
const three = await outline();
const apex = await json('__gm.texture.pointAt(32, 0)');
await click(apex.x, apex.y, 'right');
const outlineMenu = await json('__gm.menuRows()');
await clickText('Delete point');
const pair = await outline();
// All three steps back: the checks after this one count on the undo stack as it was.
await undo();
await undo();
await undo();
check(
'a double-click on an edge adds a picked corner, Delete and the menu of a corner take one away',
four?.[0]?.length === 4 &&
four[0][1].x === 16 &&
four[0][1].y === 16 &&
JSON.stringify(pickedNew) === JSON.stringify({ outline: 0, index: 1 }) &&
three?.length === 1 &&
three[0].length === 3 &&
JSON.stringify(outlineMenu) === JSON.stringify(['Delete point']) &&
pair?.[0]?.length === 2 &&
(await outline())?.[0]?.length === 3,
JSON.stringify({ four, pickedNew, three, outlineMenu, pair }),
);
// 42c-ii. A sheet full of outlines stays smooth (#220): dragging a corner redraws only the
+46 -6
View File
@@ -276,13 +276,53 @@ export async function vectorArt(t) {
await shot('47-vector-tab');
const firstPoint = await json(`__gm.vectorArt.pointAt(${TRIANGLE[0].x}, ${TRIANGLE[0].y})`);
await doubleClick(firstPoint.x, firstPoint.y);
const left = (await json("__gm.vectorArt.shapes('va-art')")).at(-1).rings[0].points;
// #288: a double-click on a line puts a point there and picks it, Delete takes the picked point
// away and leaves the shape, and a point's menu does the same.
const ringPoints = async () => (await json("__gm.vectorArt.shapes('va-art')")).at(-1).rings[0].points;
const lineSpot = await json(`__gm.vectorArt.pointAt(75, 75)`);
const linePoints = (await ringPoints()).length;
await click(lineSpot.x, lineSpot.y);
const onlyClicked = (await ringPoints()).length;
await sleep(500);
await doubleClick(lineSpot.x, lineSpot.y);
const withPoint = await ringPoints();
const pickedAdded = await json('__gm.vectorArt.picked()');
await press('Delete');
const withoutPoint = await ringPoints();
check(
'a double-click takes a point away',
left.length === 2 && near(left[0].x, 180),
JSON.stringify(left.map((p) => [Math.round(p.x), Math.round(p.y)])),
'a click on a line adds nothing, a double-click puts a picked point there, and Delete takes it away again',
onlyClicked === linePoints &&
withPoint.length === linePoints + 1 &&
Boolean(pickedAdded) &&
near(withPoint[pickedAdded.index].x, 75) &&
near(withPoint[pickedAdded.index].y, 75) &&
withoutPoint.length === linePoints &&
(await json("__gm.vectorArt.shapes('va-art')")).length > 0,
JSON.stringify({
linePoints,
onlyClicked,
withPoint: withPoint.length,
pickedAdded,
withoutPoint: withoutPoint.length,
}),
);
const firstPoint = await json(`__gm.vectorArt.pointAt(${TRIANGLE[0].x}, ${TRIANGLE[0].y})`);
await sleep(500);
await doubleClick(firstPoint.x, firstPoint.y);
const keptPoints = (await ringPoints()).length;
await click(firstPoint.x, firstPoint.y, 'right');
const pointMenu = await json('__gm.menuRows()');
await clickText('Delete point');
const left = await ringPoints();
check(
'a double-click keeps a point; its menu offers Delete point and the four types, and takes it away',
keptPoints === 3 &&
JSON.stringify(pointMenu) ===
JSON.stringify(['Delete point', null, 'Straight', 'Corner', 'Smooth', 'Symmetric']) &&
left.length === 2 &&
near(left[0].x, 180),
JSON.stringify({ keptPoints, pointMenu, left: left.map((p) => [Math.round(p.x), Math.round(p.y)]) }),
);
// The drawing is a picture like any other: what the tool changed reaches the sprite by itself.
+66 -6
View File
@@ -2,7 +2,8 @@ import { edge, node } from '../lib/builders.mjs';
/**
* Curve assets (#247): made from the + menu and opened by its row, bent in the Curve tab with the
* real mouse – a point put down on free space, turned smooth through the header, a handle pulled –
* real mouse – a point put down on free space by a double-click, turned smooth through the header, a
* handle pulled, a point taken away by Delete and by its menu –
* and then picked where a built-in ease is picked, on an animation key and on a `Lerp` node. The
* values those two produce are compared with the curve worked out here, by walking its Bezier
* segments, not with the editor's own solver.
@@ -84,15 +85,22 @@ export async function curves(t) {
JSON.stringify({ current: await json('__gm.curve.current()'), fresh }),
);
// 53b. A click on free space puts a point there, and it is the picked one.
// 53b. A double-click on free space puts a point there, and it is the picked one (#288); a click
// alone adds nothing.
const spot = await json('__gm.curve.at(0.5, 0.9)');
// Past the double-click that opened the tab, and again past the click that must not count.
await sleep(500);
await click(spot.x, spot.y);
const onlyClicked = (await pointsOf(curveId))?.length;
await sleep(500);
await doubleClick(spot.x, spot.y);
await settle();
const put = await pointsOf(curveId);
const middle = put?.[1];
check(
'a click on free space puts a point where it was clicked, between the ends, and picks it',
put?.length === 3 &&
'a double-click on free space puts a point there, between the ends, and picks it – a click adds none',
onlyClicked === 2 &&
put?.length === 3 &&
Math.abs(middle.x - 0.5) < 0.01 &&
Math.abs(middle.y - 0.9) < 0.01 &&
!middle.in &&
@@ -344,7 +352,7 @@ export async function curves(t) {
* arrow over points, the magnet, and the preview dragged by its knob and by its dot beside the graph.
*/
async function headerAndScrub(t, curveId, bounced) {
const { check, json, evaluate, click, drag, settle, shot, undo } = t;
const { check, json, evaluate, click, doubleClick, drag, press, settle, sleep, shot, undo } = t;
const pointsOf = () => json(`__gm.curve.points(${JSON.stringify(curveId)})`);
const at = (tt, v) => json(`__gm.curve.at(${tt}, ${v})`);
const onGrid = (n) => Math.abs(n / 0.05 - Math.round(n / 0.05)) < 1e-6;
@@ -425,7 +433,8 @@ async function headerAndScrub(t, curveId, bounced) {
await click(magnet.x, magnet.y);
await settle();
const put = await at(0.337, 0.412);
await click(put.x, put.y);
await sleep(500);
await doubleClick(put.x, put.y);
await settle();
const index = await json('__gm.curve.picked()');
const dropped = (await pointsOf())?.[index];
@@ -451,6 +460,57 @@ async function headerAndScrub(t, curveId, bounced) {
JSON.stringify({ magnet, dropped, dragged, restored: restored?.length }),
);
// 53j-ii. Taking a point away (#288): Delete takes the picked one, and a right-click on a point
// opens its menu – Delete point and its type; an end's Delete point is greyed out.
const between = (bounced[0].x + bounced[1].x) / 2;
const onLine = await at(between, expectedAt(bounced, between));
await sleep(500);
await doubleClick(onLine.x, onLine.y);
const added = await pointsOf();
const pickedNew = await json('__gm.curve.picked()');
await press('Delete');
const removed = await pointsOf();
const inner = await at(bounced[2].x, bounced[2].y);
await click(inner.x, inner.y, 'right');
const pointMenu = await json('__gm.menuRows()');
const innerOn = await json('__gm.menuEnabled()');
const pickedByMenu = await json('__gm.curve.picked()');
await press('Escape');
const endAt = await at(0, bounced[0].y);
await click(endAt.x, endAt.y, 'right');
const endOn = await json('__gm.menuEnabled()');
await press('Escape');
await click(inner.x, inner.y, 'right');
await t.clickText('Delete point');
const byMenu = await pointsOf();
await undo();
await undo();
await undo();
await click(aside.x, aside.y);
check(
'a double-click on the line adds a picked point, Delete and the menu of a point take one away, never an end',
added?.length === bounced.length + 1 &&
pickedNew === 1 &&
removed?.length === bounced.length &&
JSON.stringify(pointMenu) ===
JSON.stringify(['Delete point', null, 'Straight', 'Corner', 'Smooth', 'Symmetric']) &&
innerOn?.[0] === true &&
pickedByMenu === 2 &&
endOn?.[0] === false &&
byMenu?.length === bounced.length - 1 &&
JSON.stringify(await pointsOf()) === JSON.stringify(bounced),
JSON.stringify({
added: added?.length,
pickedNew,
removed: removed?.length,
pointMenu,
innerOn,
pickedByMenu,
endOn,
byMenu: byMenu?.length,
}),
);
// 53k. Held, the preview moves only when dragged: its knob sideways sets t, its dot beside the
// graph up or down jumps to the first t the curve reaches that height. The curve stays.
const before = await json('__gm.curve.preview()');