From 0f85476b93eedefb75c87c7898326c81b4901e58 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Thu, 1 Oct 2026 19:36:13 +0200 Subject: [PATCH] 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 --- README.md | 2 +- src/editor/Editor.ts | 19 + src/editor/PointKindButton.ts | 2 +- src/editor/curve/CurveCanvas.ts | 73 ++-- src/editor/debug/documentHooks.ts | 2 + src/editor/debug/vectorArtHooks.ts | 2 + src/editor/pointGestures.ts | 32 ++ src/editor/pointMenu.ts | 42 +++ src/editor/polygonEdit.ts | 4 +- src/editor/shortcuts.ts | 9 +- src/editor/texture/SheetCanvas.ts | 3 + src/editor/texture/TextureCanvas.ts | 152 ++++++-- src/editor/vectorart/VectorArtCanvas.ts | 163 +++++--- src/editor/viewport/SceneViewport.ts | 12 +- src/editor/viewport/polygonCorners.ts | 128 +++++-- tests/editor/point-gestures.test.ts | 481 ++++++++++++++++++++++++ tools/e2e/42-shapes.mjs | 98 ++++- tools/e2e/47-vectorart.mjs | 52 ++- tools/e2e/53-curves.mjs | 72 +++- 19 files changed, 1180 insertions(+), 168 deletions(-) create mode 100644 src/editor/pointGestures.ts create mode 100644 src/editor/pointMenu.ts create mode 100644 tests/editor/point-gestures.test.ts diff --git a/README.md b/README.md index 93a5cbc7..cd4cc42e 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1625 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1629 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 90713666..2f2f846f 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -461,6 +461,7 @@ export class Editor implements PlayHost { copy: () => (this.tab === 'animation' && this.animationEditor.canvas.copyKeys()) || copySelection(state), paste: () => this.paste(), + removePoint: () => this.removePoint(), }), ); const onTick = (ticker: Ticker) => this.tick(ticker.deltaMS / 1000); @@ -542,6 +543,24 @@ export class Editor implements PlayHost { return this.tab === 'scene' && pickView(this.state, pick.holderId) === 'scene'; } + /** Delete takes the point the visible tab has picked (#288), wherever the keyboard focus is. */ + private removePoint(): boolean { + switch (this.tab) { + case 'scene': + return this.viewport.removePickedCorner(); + case 'container': + return this.containerEditor.canvas.removePickedCorner(); + case 'texture': + return this.textureEditor.canvas.removePickedPoint(); + case 'vectorart': + return this.vectorArtEditor.canvas.removePickedPoint(); + case 'curve': + return this.curveEditor.canvas.removePickedPoint(); + default: + return false; + } + } + /** Ctrl+V lands in whatever the visible tab edits (#240). */ private paste(): boolean { switch (this.tab) { diff --git a/src/editor/PointKindButton.ts b/src/editor/PointKindButton.ts index 2a7b5948..80512131 100644 --- a/src/editor/PointKindButton.ts +++ b/src/editor/PointKindButton.ts @@ -4,7 +4,7 @@ import { POINT_KINDS, type PointKind } from './polygonEdit'; /** Fixed, so the header does not shift when "Point: Symmetric" becomes "Point: Corner". */ export const POINT_BUTTON_WIDTH = 150; -const POINT_KIND_LABELS: Record = { +export const POINT_KIND_LABELS: Record = { straight: 'Straight', corner: 'Corner', smooth: 'Smooth', diff --git a/src/editor/curve/CurveCanvas.ts b/src/editor/curve/CurveCanvas.ts index f15afb8b..11c6c3d8 100644 --- a/src/editor/curve/CurveCanvas.ts +++ b/src/editor/curve/CurveCanvas.ts @@ -16,7 +16,9 @@ import { findAssetOf } from '../../core/model/Project'; import type { Vec2 } from '../../core/model/Properties'; import { cancelFrameWork, onFrame, pressCount, Theme, textStyle } from '../../ui'; import { FramePreview } from '../framePreview'; +import { deleteTakes, pickedIn, pointPress } from '../pointGestures'; import { DRAG_THRESHOLD, drawPointGrip, POINT_SIZE } from '../pointGrips'; +import { openPointMenu } from '../pointMenu'; import { type PointKind, pointKind, @@ -238,10 +240,16 @@ export class CurveCanvas extends SheetCanvas { /** Delete takes the picked point away – never an end, which holds the curve at 0 or 1. */ override onKeyDown(event: KeyboardEvent): boolean { + const removes = event.key === 'Delete' || event.key === 'Backspace'; + return (removes && this.removePickedPoint()) || super.onKeyDown(event); + } + + /** False while no point is picked, so Delete goes on to what it takes otherwise. */ + removePickedPoint(): boolean { const asset = this.asset(); - if ((event.key !== 'Delete' && event.key !== 'Backspace') || !asset || this.picked < 0) - return super.onKeyDown(event); - this.removePoint(asset, this.picked); + const picked = asset ? pickedIn(asset.points.length, this.picked) : -1; + if (!asset || deleteTakes(picked >= 0) !== 'point') return false; + this.removePoint(asset, picked); return true; } @@ -257,11 +265,34 @@ export class CurveCanvas extends SheetCanvas { return this.asset(); } + /** A right-click on a point picks it and opens its menu: Delete point and its type (#288). */ + protected override context(asset: CurveAsset, e: FederatedPointerEvent, local: Vec2): void { + const ring = curveToImage(asset.points); + const at = localToImage(this.view, local.x, local.y); + const grip = ringGripAt(ring, at, POINT_SIZE / this.view.zoom); + if (grip?.kind !== 'point') return; + e.stopPropagation(); + const index = grip.index; + this.pick(index); + this.redraw(); + openPointMenu( + { + removable: !isCurveEnd(ring, index), + remove: () => this.removePoint(asset, index), + kind: this.pickedKind, + setKind: (kind) => this.setPickedKind(kind), + }, + e.global.x, + e.global.y, + this, + ); + } + /** - * A press: on a handle it drags the handle, on a point the point – a double-click takes the point - * away, Alt makes it round or straight again. On the line it puts a point there that keeps the - * curve as it was, on free space a plain one at the pointer. Either way the new point follows the - * pointer until it lets go. + * A press: on a handle it drags the handle, on a point it picks the point and drags it – Alt makes + * it round or straight again. Anywhere else a click only drops the pick; a double-click puts a + * point there (#288), on the line one that keeps the curve as it was, on free space a plain one at + * the pointer. The new point is picked and stays where it is until it is pressed again. */ protected press(asset: CurveAsset, e: FederatedPointerEvent, local: Vec2): void { e.stopPropagation(); @@ -270,21 +301,17 @@ export class CurveCanvas extends SheetCanvas { const grip = ringGripAt(ring, at, POINT_SIZE / this.view.zoom); if (grip?.kind === 'point') { this.pick(grip.index); - if (pressCount(e, `curve point ${asset.id} ${grip.index}`) === 2) { - this.removePoint(asset, grip.index); - return; - } if (e.altKey) { const points = imageToCurve(withSmoothToggled(ring, grip.index)); this.state.execute(new SetCurvePointsCommand(asset.id, points, 'Smooth point')); return; } - this.startDrag(asset, ring, grip.index, 'point', 'out', local, false, 'Move point'); + this.startDrag(asset, ring, grip.index, 'point', 'out', local, 'Move point'); return; } if (grip?.kind === 'handle') { this.pick(grip.index); - this.startDrag(asset, ring, grip.index, 'handle', grip.side, local, false, 'Bend curve'); + this.startDrag(asset, ring, grip.index, 'handle', grip.side, local, 'Bend curve'); return; } const scrub = grip?.kind === 'segment' ? null : this.scrubAt(asset.points, local); @@ -293,18 +320,23 @@ export class CurveCanvas extends SheetCanvas { this.scrubTo(asset.points, local); return; } + const spot = grip?.kind === 'segment' ? 'line' : 'free'; const put = - grip?.kind === 'segment' - ? { ring: withPointOnSegment(ring, grip.index, grip.t), index: grip.index + 1 } - : withCurvePointAt(ring, this.snapped(at)); + pointPress(spot, pressCount(e, `curve add ${asset.id}`), true) !== 'add' + ? null + : grip?.kind === 'segment' + ? { ring: withPointOnSegment(ring, grip.index, grip.t), index: grip.index + 1 } + : withCurvePointAt(ring, this.snapped(at)); if (!put) { this.pick(-1); this.redraw(); return; } + const points = sanitizedCurve(imageToCurve(put.ring)); + if (!SetCurvePointsCommand.changes(asset, points)) return; + this.state.execute(new SetCurvePointsCommand(asset.id, points, 'Add point')); this.pick(put.index); - this.startDrag(asset, put.ring, put.index, 'point', 'out', local, true, 'Add point'); - this.preview(); + this.redraw(); } private startDrag( @@ -314,7 +346,6 @@ export class CurveCanvas extends SheetCanvas { kind: CurveDrag['kind'], side: 'in' | 'out', pointer: Vec2, - moved: boolean, label: string, ): void { this.drag = { @@ -325,7 +356,7 @@ export class CurveCanvas extends SheetCanvas { index, side, pointer: { x: pointer.x, y: pointer.y }, - moved, + moved: false, label, }; this.redraw(); @@ -509,7 +540,7 @@ export class CurveCanvas extends SheetCanvas { private syncHint(asset: CurveAsset | null): void { this.hint.text = asset - ? 'Click the line or free space to add a point, drag handles to bend, double-click a point to remove it.' + ? 'Double-click to add a point, drag handles to bend, Delete or right-click removes the picked point.' : 'No curve selected – pick one in the Assets panel.'; this.hint.visible = true; this.hint.position.set(Theme.padding * 2, Theme.padding * 2); diff --git a/src/editor/debug/documentHooks.ts b/src/editor/debug/documentHooks.ts index bb8c071f..226f0661 100644 --- a/src/editor/debug/documentHooks.ts +++ b/src/editor/debug/documentHooks.ts @@ -21,6 +21,8 @@ export function documentHooks({ state, editor }: HookContext) { rect: () => textureEditor.canvasRect(), /** Whether a press edits the selected frame's collision outline (#141). */ collisionMode: () => textureEditor.canvas.collisionMode, + /** The picked corner of the selected frame's outlines (#288), `{ outline, index }` or null. */ + pickedCorner: () => textureEditor.canvas.pickedCorner, /** Rebuilds of the unselected frames' marks, their outlines, and where marks and names sit (#220). */ drawInfo: () => textureEditor.canvas.drawInfo, /** The open "Generate collision…" dialog (#161), or null. */ diff --git a/src/editor/debug/vectorArtHooks.ts b/src/editor/debug/vectorArtHooks.ts index b060c5ee..8e2453ac 100644 --- a/src/editor/debug/vectorArtHooks.ts +++ b/src/editor/debug/vectorArtHooks.ts @@ -16,6 +16,8 @@ export function vectorArtHooks({ state, editor }: HookContext) { snapping: () => vectorArtEditor.canvas.snapping, cursor: () => vectorArtEditor.canvas.cursor, snapButton: () => snapButton(vectorArtEditor.snap), + /** The picked point of the selected shape (#288), `{ ring, index }` or null. */ + picked: () => vectorArtEditor.canvas.pickedAt, /** Whether the pen has a line open that the next click would add a point to. */ drawing: () => vectorArtEditor.canvas.drawing, /** The header's point type (#195): where to click it, what it says, and the type it reads. */ diff --git a/src/editor/pointGestures.ts b/src/editor/pointGestures.ts new file mode 100644 index 00000000..2cd89ba7 --- /dev/null +++ b/src/editor/pointGestures.ts @@ -0,0 +1,32 @@ +/** + * What a press means in an editor of points (#288) – the Curve tab, the Vector art tab's arrow, a + * polygon collider in the Scene view and a frame's collision outline in the Texture tab. One rule + * for all four, and pure, so it is testable without a view: a point is picked by a click and goes + * by Delete or by its menu, and a new one takes a double-click. + */ + +/** Where a press landed: on a point, on the line between two, or beside both. */ +export type PointSpot = 'point' | 'line' | 'free'; + +/** What it does: pick the point under it, put a point there, or drop the pick. */ +export type PointPress = 'pick' | 'add' | 'clear'; + +/** + * `presses` is the press's count in a row, 2 on a double-click. Only a double-click adds, so a + * click that misses a point changes nothing but the pick. `addsOnFree` is for an editor that puts + * points beside the line, too – a curve, whose points need not lie on what is drawn. + */ +export function pointPress(spot: PointSpot, presses: number, addsOnFree = false): PointPress { + if (spot === 'point') return 'pick'; + return presses === 2 && (spot === 'line' || addsOnFree) ? 'add' : 'clear'; +} + +/** What Delete takes away: the picked point while there is one, else what the editor's selection is. */ +export function deleteTakes(pointPicked: boolean): 'point' | 'selection' { + return pointPicked ? 'point' : 'selection'; +} + +/** A point picked in a list of them; -1 once it has gone – an undo, or another object selected. */ +export function pickedIn(count: number, picked: number): number { + return Number.isInteger(picked) && picked >= 0 && picked < count ? picked : -1; +} diff --git a/src/editor/pointMenu.ts b/src/editor/pointMenu.ts new file mode 100644 index 00000000..795009a6 --- /dev/null +++ b/src/editor/pointMenu.ts @@ -0,0 +1,42 @@ +import { Menu, type MenuEntry, ui, type Widget } from '../ui'; +import { POINT_KIND_LABELS } from './PointKindButton'; +import { POINT_KINDS, type PointKind } from './polygonEdit'; + +/** What the menu of a point acts on. */ +export interface PointMenuTarget { + /** False while the point must stay: an end of a curve, one of a polygon's last three corners. */ + removable: boolean; + remove(): void; + /** The point's type and how to change it – only where points carry handles. */ + kind?: PointKind | null; + setKind?(kind: PointKind): void; +} + +/** The entries of a point's menu (#288): Delete point, and its type where it has one. */ +export function pointMenuEntries(target: PointMenuTarget): MenuEntry[] { + const entries: MenuEntry[] = [ + { label: 'Delete point', danger: true, disabled: !target.removable, onSelect: () => target.remove() }, + ]; + const setKind = target.setKind?.bind(target); + if (!setKind || target.kind == null) return entries; + entries.push({ separator: true }); + for (const kind of POINT_KINDS) + entries.push({ + label: POINT_KIND_LABELS[kind], + checked: () => target.kind === kind, + onSelect: () => setKind(kind), + }); + return entries; +} + +/** + * Opens it at a global pointer position – where the right-click was. A popup takes the keyboard + * away; `keys` gets it back when the menu closes, so Delete still goes to the tab that has the + * point picked and not on to the selection. + */ +export function openPointMenu(target: PointMenuTarget, x: number, y: number, keys?: Widget): Menu { + const menu = new Menu(pointMenuEntries(target)); + if (keys) menu.onClose = () => ui().focus.focus(keys); + menu.open(x, y); + return menu; +} diff --git a/src/editor/polygonEdit.ts b/src/editor/polygonEdit.ts index 4861eb67..f8439715 100644 --- a/src/editor/polygonEdit.ts +++ b/src/editor/polygonEdit.ts @@ -1,7 +1,7 @@ /** * Editing an outline by its corners (#141, #151), the same in the Scene view (a polygon collider) - * and in the Texture tab (a frame's collision outline): drag a corner, press on an edge to put a - * corner there, double-click a corner to take it away. Pure, so the rules are testable without a view. + * and in the Texture tab (a frame's collision outline): drag a corner, put one on an edge, take one + * away. Pure, so the rules are testable without a view; which press does which is `pointGestures.ts`. * * The lower half of the file says the same about a ring of a vector art shape (#129, stage 3). It is * the same set of gestures on a richer point: one that may carry Bezier handles. They are kept here diff --git a/src/editor/shortcuts.ts b/src/editor/shortcuts.ts index 8b6570c5..3169f6db 100644 --- a/src/editor/shortcuts.ts +++ b/src/editor/shortcuts.ts @@ -12,6 +12,12 @@ export interface ShortcutActions { */ copy(): boolean; paste(): boolean; + /** + * Delete on a point the visible tab has picked inside the selection (#288) – a corner of a polygon + * collider or of an outline, a point of a shape or a curve. False while none is, so the key takes + * the selection itself. + */ + removePoint(): boolean; } /** @@ -48,7 +54,8 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions): if (s?.kind === 'object') createContainer(state, s.holderId, s.objectIds); return true; } - if (event.key === 'Delete' || event.key === 'Backspace') return removeSelection(state); + if (event.key === 'Delete' || event.key === 'Backspace') + return actions.removePoint() || removeSelection(state); // Ctrl+Enter starts play and then holds/resumes it. A plain letter would be taken away from // the game, which owns the keyboard while playing. if (mod && event.key === 'Enter') { diff --git a/src/editor/texture/SheetCanvas.ts b/src/editor/texture/SheetCanvas.ts index 6ae771ec..6f580cfe 100644 --- a/src/editor/texture/SheetCanvas.ts +++ b/src/editor/texture/SheetCanvas.ts @@ -44,6 +44,8 @@ export abstract class SheetCanvas extends Widget { /** The pointer moved while no pan runs. */ protected abstract move(local: Vec2): void; protected abstract release(): void; + /** A right-click; a tab with a menu for what lies under the pointer opens it here. */ + protected context(_target: A, _e: FederatedPointerEvent, _local: Vec2): void {} override measure(): Size { return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) }; @@ -104,6 +106,7 @@ export abstract class SheetCanvas extends Widget { e.stopPropagation(); return; } + if (e.button === 2) this.context(target, e, { x: local.x, y: local.y }); if (e.button !== 0) return; ui().focus.focus(this); this.press(target, e, { x: local.x, y: local.y }); diff --git a/src/editor/texture/TextureCanvas.ts b/src/editor/texture/TextureCanvas.ts index 5dadd4dd..1e2f07d4 100644 --- a/src/editor/texture/TextureCanvas.ts +++ b/src/editor/texture/TextureCanvas.ts @@ -30,7 +30,6 @@ import { gripAt, insideOutline, MIN_CORNERS, - type PolygonGrip, withCornerAfter, withCornerMoved, withoutCorner, @@ -38,6 +37,8 @@ import { import { COLLIDER_FILL_ALPHA, dashedLine } from '../../viewport/symbols'; import { textureLibrary } from '../../viewport/TextureLibrary'; import { FramePreview } from '../framePreview'; +import { deleteTakes, pickedIn, pointPress } from '../pointGestures'; +import { openPointMenu } from '../pointMenu'; import type { EditorState } from '../state'; import { drawChecker } from './checker'; import { SheetCanvas } from './SheetCanvas'; @@ -145,6 +146,8 @@ export class TextureCanvas extends SheetCanvas { private collision = false; /** The outline a click adds corners to; for another frame, or one gone, the frame's last one. */ private active = { frameId: '', index: -1 }; + /** The corner last pressed or put down (#288): Delete takes it rather than its whole outline. */ + private picked: { frameId: string; outline: number; index: number } | null = null; private readonly stopPictures: () => void; private readonly perFrame: FramePreview; @@ -156,9 +159,13 @@ export class TextureCanvas extends SheetCanvas { this.content.addChild(this.image, this.plates, this.marks, this.labelLayer); state.on('project', () => this.redraw()); - state.on('selection', () => this.redraw()); + state.on('selection', () => { + if (this.picked && this.picked.frameId !== this.selectedFrameId()) this.picked = null; + this.redraw(); + }); state.on('load', () => { this.needsFit = true; + this.picked = null; this.redraw(); }); // A picture arrives asynchronously; until then there is nothing to fit the view to. @@ -176,15 +183,45 @@ export class TextureCanvas extends SheetCanvas { if (this.assetId === assetId) return; this.assetId = assetId; this.needsFit = true; + this.picked = null; this.redraw(); } setCollisionMode(on: boolean): void { if (this.collision === on) return; this.collision = on; + this.picked = null; this.redraw(); } + /** The picked corner of the selected frame's outlines, null for none (test hook, and what is drawn filled). */ + get pickedCorner(): { outline: number; index: number } | null { + const frame = this.frame(this.selectedFrameId()); + const picked = this.picked; + if (!this.collision || !frame || picked?.frameId !== frame.id) return null; + const corners = frameCollision(frame)[picked.outline]?.length ?? 0; + return pickedIn(corners, picked.index) < 0 ? null : { outline: picked.outline, index: picked.index }; + } + + /** False while no corner is picked, so Delete goes on to the active outline, or to the frame. */ + removePickedPoint(): boolean { + if (deleteTakes(this.pickedCorner !== null) !== 'point') return false; + this.removePicked(); + return true; + } + + /** Takes the picked corner out of its outline; an outline left with no corner goes with it. */ + private removePicked(): void { + const frame = this.frame(this.selectedFrameId()); + const picked = this.pickedCorner; + if (!frame || !picked) return; + const outlines = frameCollision(frame); + const next = outlines.map((o, i) => (i === picked.outline ? withoutCorner(o, picked.index, 0)! : o)); + this.active = { frameId: frame.id, index: next[picked.outline]!.length > 0 ? picked.outline : -1 }; + this.picked = null; + this.state.execute(new SetFrameCollisionCommand(this.assetId, frame.id, next)); + } + get collisionMode(): boolean { return this.collision; } @@ -235,12 +272,16 @@ export class TextureCanvas extends SheetCanvas { return frameId === frame.id && index < outlines.length ? index : outlines.length - 1; } - /** Delete takes the active outline away whole, in collision mode – not the frame it belongs to. */ + /** + * Delete in collision mode takes the picked corner away (#288), and the active outline whole + * while none is picked – never the frame they belong to. + */ override onKeyDown(event: KeyboardEvent): boolean { if (!this.collision || (event.key !== 'Delete' && event.key !== 'Backspace')) return super.onKeyDown(event); const frame = this.frame(this.selectedFrameId()); if (!frame) return super.onKeyDown(event); + if (this.removePickedPoint()) return true; const outlines = frameCollision(frame); const index = this.activeOutline(frame, outlines); // Nothing active is nothing to delete – and still not the frame, which Delete would take otherwise. @@ -320,13 +361,42 @@ export class TextureCanvas extends SheetCanvas { e.stopPropagation(); } + /** The corner or edge of the selected frame's outlines under an image pixel, the active outline first. */ + private outlineGripAt(frame: TextureFrame, point: Vec2, tolerance: number) { + const at = { x: point.x - frame.x, y: point.y - frame.y }; + const outlines = frameCollision(frame); + const active = this.activeOutline(frame, outlines); + // The active outline first, so its corners stay reachable where two outlines meet. + const order = outlines.map((_, i) => i).sort((a, b) => Number(b === active) - Number(a === active)); + for (const target of order) { + const grip = gripAt(outlines[target]!, at, tolerance); + if (grip) return { at, outlines, active, target, grip }; + } + return { at, outlines, active, target: active, grip: null }; + } + + /** A right-click on a corner in collision mode picks it and opens its menu (#288). */ + protected override context(_asset: TextureAsset, e: FederatedPointerEvent, local: Vec2): void { + const selected = this.collision ? this.frame(this.selectedFrameId()) : undefined; + if (!selected) return; + const point = localToImage(this.view, local.x, local.y); + const { target, grip } = this.outlineGripAt(selected, point, HANDLE_SIZE / this.view.zoom); + if (grip?.kind !== 'corner') return; + e.stopPropagation(); + this.active = { frameId: selected.id, index: target }; + this.picked = { frameId: selected.id, outline: target, index: grip.index }; + this.redraw(); + openPointMenu({ removable: true, remove: () => this.removePicked() }, e.global.x, e.global.y, this); + } + /** - * A press in collision mode. On the selected frame, on any of its outlines: a double-click on a - * corner takes it away, a press on a corner drags it, on an edge puts a corner there – and makes - * that outline the active one. Anywhere else inside the frame adds a corner after the active - * outline's last, which is how an outline is drawn from nothing; with Ctrl (Cmd) held it starts a - * new outline, and a press inside another finished outline only makes it the active one. Another - * frame is only selected, and no frame is moved or spanned while the mode is on. + * A press in collision mode. On the selected frame, on any of its outlines: a press on a corner + * picks it and drags it; on an edge a click only makes that outline the active one, and a + * double-click puts a corner there, picked and in place until it is pressed again (#288). + * Anywhere else inside the frame adds a corner after the active outline's last, which is how an + * outline is drawn from nothing; with Ctrl (Cmd) held it starts a new outline, and a press inside + * another finished outline only makes it the active one. Another frame is only selected, and no + * frame is moved or spanned while the mode is on. */ private pressOutline( asset: TextureAsset, @@ -337,24 +407,21 @@ export class TextureCanvas extends SheetCanvas { ): void { const selected = this.frame(this.selectedFrameId()); if (selected) { - const at = { x: point.x - selected.x, y: point.y - selected.y }; - const outlines = frameCollision(selected); - const active = this.activeOutline(selected, outlines); - // The active outline first, so its corners stay reachable where two outlines meet. - const order = outlines.map((_, i) => i).sort((a, b) => Number(b === active) - Number(a === active)); - let target = active; - let grip: PolygonGrip | null = null; - for (const i of order) { - grip = gripAt(outlines[i]!, at, tolerance); - if (!grip) continue; - target = i; - break; - } + const under = this.outlineGripAt(selected, point, tolerance); + const { at, outlines, active, grip } = under; + let target = under.target; const activate = (index: number) => (this.active = { frameId: selected.id, index }); - if (grip?.kind === 'corner' && pressCount(e, `outline ${selected.id} ${target} ${grip.index}`) === 2) { - const next = outlines.map((o, i) => (i === target ? withoutCorner(o, grip.index, 0)! : o)); - activate(next[target]!.length > 0 ? target : -1); - this.state.execute(new SetFrameCollisionCommand(asset.id, selected.id, next)); + const pick = (outline: number, index: number) => + (this.picked = { frameId: selected.id, outline, index }); + if (grip?.kind === 'edge') { + activate(target); + this.picked = null; + if (pointPress('line', pressCount(e, `outline add ${selected.id}`)) === 'add') { + const next = [...outlines]; + next[target] = withCornerAfter(outlines[target]!, grip.index, inFrame(selected, grip.at)); + pick(target, grip.index + 1); + this.state.execute(new SetFrameCollisionCommand(asset.id, selected.id, next)); + } else this.redraw(); return; } // A corner often belongs on the frame's edge: a press just beside it still counts as in it. @@ -368,31 +435,28 @@ export class TextureCanvas extends SheetCanvas { const other = outlines.findIndex((o, i) => i !== active && insideOutline(o, at)); if (other >= 0) { activate(other); + this.picked = null; this.redraw(); return; } } if (grip || inside) { - const corner = inFrame(selected, grip?.kind === 'edge' ? grip.at : at); if (!grip && fresh) target = outlines.length; const points = outlines[target] ?? []; - const changed = - grip?.kind === 'corner' - ? points - : grip?.kind === 'edge' - ? withCornerAfter(points, grip.index, corner) - : [...points, corner]; + const changed = grip ? points : [...points, inFrame(selected, at)]; const next = [...outlines]; next[target] = changed; activate(target); + const index = grip ? grip.index : changed.length - 1; + pick(target, index); this.corner = { frameId: selected.id, before: outlines, outlines: next, outline: target, - index: grip?.kind === 'corner' ? grip.index : grip ? grip.index + 1 : changed.length - 1, + index, pointer: { x: local.x, y: local.y }, - added: grip?.kind !== 'corner', + added: !grip, moved: false, }; if (this.corner.added) this.previewOutline(); @@ -400,6 +464,7 @@ export class TextureCanvas extends SheetCanvas { return; } } + this.picked = null; for (let i = asset.frames.length - 1; i >= 0; i--) { const frame = asset.frames[i]!; if (handleAt(frame, point, tolerance) === null) continue; @@ -766,10 +831,19 @@ export class TextureCanvas extends SheetCanvas { g.moveTo(screen[0]!.x, screen[0]!.y).lineTo(screen[1]!.x, screen[1]!.y).stroke({ width: 2, color }); if (!edited) continue; const half = HANDLE_SIZE / 2; - for (const c of screen) + const picked = this.pickedCorner; + for (const [corner, c] of screen.entries()) { + const isPicked = picked?.outline === index && picked.index === corner; g.rect(c.x - half, c.y - half, HANDLE_SIZE, HANDLE_SIZE) - .fill(index === active ? Theme.colors.accentText : Theme.colors.viewport) - .stroke({ width: 1, color }); + .fill( + isPicked + ? Theme.colors.accent + : index === active + ? Theme.colors.accentText + : Theme.colors.viewport, + ) + .stroke({ width: 1, color: isPicked ? Theme.colors.accentText : color }); + } } return tinted; } @@ -832,7 +906,7 @@ export class TextureCanvas extends SheetCanvas { ? 'No texture selected – pick one in the Assets panel or import an image.' : this.collision && asset.file && hasPicture ? this.selectedFrameId() - ? 'Collision: click adds a corner, Ctrl+click starts a new outline, Delete removes one.' + ? 'Collision: click adds a corner, double-click an edge too, Ctrl+click a new outline, Delete removes.' : 'Collision: select a frame to draw the outlines its tiles block with.' : !asset.file ? 'This texture has no picture yet.' diff --git a/src/editor/vectorart/VectorArtCanvas.ts b/src/editor/vectorart/VectorArtCanvas.ts index 99b52beb..3a2e0da0 100644 --- a/src/editor/vectorart/VectorArtCanvas.ts +++ b/src/editor/vectorart/VectorArtCanvas.ts @@ -47,7 +47,9 @@ import { withoutPoint, withSmoothToggled, } from '../polygonEdit'; +import { deleteTakes, pointPress } from '../pointGestures'; import { DRAG_THRESHOLD, drawPointGrip, HANDLE_RADIUS, POINT_SIZE } from '../pointGrips'; +import { openPointMenu } from '../pointMenu'; import { removeShapes } from '../actions'; import { type EditorState, shapeSelection } from '../state'; import { drawChecker } from '../texture/checker'; @@ -195,6 +197,12 @@ export class VectorArtCanvas extends SheetCanvas { return this.pen.ring >= 0; } + /** Which point is picked, null for none – the check scripts read it. */ + get pickedAt(): { ring: number; index: number } | null { + const picked = this.picked; + return picked && this.pickedPoint() ? { ring: picked.ring, index: picked.index } : null; + } + /** The type of the picked point; null while no point is picked or it has gone since (an undo). */ get pickedKind(): PointKind | null { const point = this.pickedPoint(); @@ -244,7 +252,7 @@ export class VectorArtCanvas extends SheetCanvas { return asset ? findShape(asset, shapeId) : undefined; } - /** Delete takes the selected shapes away – the canvas edits shapes, so that is what it removes. */ + /** Delete takes the picked point away (#288), and the selected shapes while none is picked. */ override onKeyDown(event: KeyboardEvent): boolean { if (event.key === 'Escape' && this.drawing) { this.endPen(); @@ -252,6 +260,7 @@ export class VectorArtCanvas extends SheetCanvas { return true; } if (event.key !== 'Delete' && event.key !== 'Backspace') return super.onKeyDown(event); + if (this.removePickedPoint()) return true; const shapeIds = this.selectedShapeIds(); if (shapeIds.length === 0) return super.onKeyDown(event); this.endPen(); @@ -259,10 +268,67 @@ export class VectorArtCanvas extends SheetCanvas { return true; } + /** Whether a point is picked and drawn as such – the paint tool shows no points. */ + private pointShown(): boolean { + return this.tool !== 'paint' && this.pickedPoint() !== undefined; + } + + /** False while no point is picked, so Delete goes on to the selected shapes. */ + removePickedPoint(): boolean { + if (deleteTakes(this.pointShown()) !== 'point') return false; + this.removePicked(); + return true; + } + + /** + * Takes the picked point away. A ring left without a point goes with it, and a shape left without + * a ring too: nothing of it could be seen or picked again. + */ + private removePicked(): void { + const picked = this.picked; + const shape = picked && this.shape(picked.shapeId); + const ring = shape?.rings[picked?.ring ?? -1]; + if (!picked || !shape || !ring?.points[picked.index]) return; + const next = withoutPoint(ring, picked.index); + const rings = next + ? this.ringsWith(shape, picked.ring, next) + : shape.rings.filter((_, i) => i !== picked.ring).map(cloneVectorRing); + this.pick(null); + if (!next && this.pen.shapeId === shape.id) this.endPen(); + if (rings.length === 0) removeShapes(this.state, this.assetId, [shape.id]); + else this.state.execute(new SetVectorShapeRingsCommand(this.assetId, shape.id, rings, 'Remove point')); + } + protected pressTarget(): VectorArtAsset | null { return this.asset(); } + /** A right-click on a point of the selected shape picks it and opens its menu (#288). */ + protected override context(_asset: VectorArtAsset, e: FederatedPointerEvent, local: Vec2): void { + const selected = this.tool === 'paint' ? undefined : this.shape(this.selectedShapeId()); + if (!selected) return; + const at = localToImage(this.view, local.x, local.y); + for (const [ringIndex, ring] of selected.rings.entries()) { + const grip = ringGripAt(ring, at, POINT_SIZE / this.view.zoom); + if (grip?.kind !== 'point') continue; + e.stopPropagation(); + this.pick({ shapeId: selected.id, ring: ringIndex, index: grip.index }); + this.redraw(); + openPointMenu( + { + removable: true, + remove: () => this.removePicked(), + kind: this.pickedKind, + setKind: (kind) => this.setPickedKind(kind), + }, + e.global.x, + e.global.y, + this, + ); + return; + } + } + protected press(asset: VectorArtAsset, e: FederatedPointerEvent, local: Vec2): void { const at = localToImage(this.view, local.x, local.y); const tolerance = POINT_SIZE / this.view.zoom; @@ -333,10 +399,11 @@ export class VectorArtCanvas extends SheetCanvas { /** * A press with the arrow. On the selected shape first, so its points stay reachable under a shape - * drawn later: a double-click on a point takes it away, Alt makes it round or square again, a - * press on a point or a handle drags it, one on a line puts a point there. Inside a shape's fill - * the whole shape is dragged; on nothing the selection is cleared. Ctrl adds a shape to the - * selection or takes it out again, which is how shapes are picked for combining (#187). + * drawn later: a press on a point picks it and drags it, Alt makes it round or square again, a + * press on a handle drags that, and a double-click on a line puts a point there (#288). Inside a + * shape's fill, and on its line, the whole shape is dragged; on nothing the selection is cleared. + * Ctrl adds a shape to the selection or takes it out again, which is how shapes are picked for + * combining (#187). */ private pressSelect( asset: VectorArtAsset, @@ -366,21 +433,7 @@ export class VectorArtCanvas extends SheetCanvas { if (shape) { this.pick(null); this.state.select(shapeSelection(asset.id, [shape.id])); - this.drag = { - kind: 'shape', - shapeId: shape.id, - before: shape.rings, - rings: shape.rings.map(cloneVectorRing), - ring: -1, - index: -1, - side: 'out', - pointer: { x: local.x, y: local.y }, - at, - moved: false, - label: 'Move shape', - delta: { x: 0, y: 0 }, - paints: paintsOf(shape), - }; + this.dragShape(shape, at, local); return; } this.pick(null); @@ -388,6 +441,24 @@ export class VectorArtCanvas extends SheetCanvas { this.redraw(); } + private dragShape(shape: VectorShape, at: Vec2, local: Vec2): void { + this.drag = { + kind: 'shape', + shapeId: shape.id, + before: shape.rings, + rings: shape.rings.map(cloneVectorRing), + ring: -1, + index: -1, + side: 'out', + pointer: { x: local.x, y: local.y }, + at, + moved: false, + label: 'Move shape', + delta: { x: 0, y: 0 }, + paints: paintsOf(shape), + }; + } + /** * A press with the paint tool: on a handle of the shown paint it drags that handle – `to` first, * so a gradient squeezed to nothing can be pulled out again – anywhere else it picks the shape @@ -438,43 +509,42 @@ export class VectorArtCanvas extends SheetCanvas { local: Vec2, e: FederatedPointerEvent, ): void { - const index = grip.kind === 'segment' ? grip.index + 1 : grip.index; - this.pick({ shapeId: shape.id, ring: ringIndex, index }); - if (grip.kind === 'point') { - if (pressCount(e, `point ${shape.id} ${ringIndex} ${grip.index}`) === 2) { - const next = withoutPoint(ring, grip.index); - const rings = next - ? this.ringsWith(shape, ringIndex, next) - : shape.rings.filter((_, i) => i !== ringIndex).map(cloneVectorRing); + if (grip.kind === 'segment') { + // The new point is picked and stays put; until the double-click the line carries its shape. + if (pointPress('line', pressCount(e, `vector add ${shape.id}`)) !== 'add') { this.pick(null); - this.state.execute(new SetVectorShapeRingsCommand(asset.id, shape.id, rings, 'Remove point')); - return; - } - if (e.altKey) { - const rings = this.ringsWith(shape, ringIndex, withSmoothToggled(ring, grip.index)); - this.state.execute(new SetVectorShapeRingsCommand(asset.id, shape.id, rings, 'Smooth point')); + this.dragShape(shape, at, local); + this.redraw(); return; } + const rings = this.ringsWith(shape, ringIndex, withPointOnSegment(ring, grip.index, grip.t)); + this.state.execute(new SetVectorShapeRingsCommand(asset.id, shape.id, rings, 'Add point')); + this.pick({ shapeId: shape.id, ring: ringIndex, index: grip.index + 1 }); + this.redraw(); + return; + } + this.pick({ shapeId: shape.id, ring: ringIndex, index: grip.index }); + if (grip.kind === 'point' && e.altKey) { + const rings = this.ringsWith(shape, ringIndex, withSmoothToggled(ring, grip.index)); + this.state.execute(new SetVectorShapeRingsCommand(asset.id, shape.id, rings, 'Smooth point')); + return; } - const ringNow = - grip.kind === 'segment' ? withPointOnSegment(ring, grip.index, grip.t) : cloneVectorRing(ring); this.drag = { - kind: grip.kind === 'handle' ? 'handle' : 'point', + kind: grip.kind, shapeId: shape.id, before: shape.rings, - rings: this.ringsWith(shape, ringIndex, ringNow), + rings: shape.rings.map(cloneVectorRing), ring: ringIndex, - index, + index: grip.index, side: grip.kind === 'handle' ? grip.side : 'out', pointer: { x: local.x, y: local.y }, at, - moved: grip.kind === 'segment', - label: grip.kind === 'segment' ? 'Add point' : grip.kind === 'handle' ? 'Bend shape' : 'Move point', + moved: false, + label: grip.kind === 'handle' ? 'Bend shape' : 'Move point', delta: { x: 0, y: 0 }, paints: {}, }; - if (grip.kind === 'segment') this.preview(); - else this.redraw(); + this.redraw(); } protected move(local: Vec2): void { @@ -632,9 +702,12 @@ export class VectorArtCanvas extends SheetCanvas { const at = localToImage(this.view, local.x, local.y); const tolerance = POINT_SIZE / this.view.zoom; const selected = this.shape(this.selectedShapeId()); - const onGrip = selected?.rings.some((ring) => ringGripAt(ring, at, tolerance) !== null) ?? false; + const grips = selected?.rings.map((ring) => ringGripAt(ring, at, tolerance)?.kind) ?? []; + // Its line carries the selected shape as its fill does; a point or a handle is its own grip. + const onLine = grips.includes('segment'); + const onGrip = grips.includes('point') || grips.includes('handle'); const inside = asset.shapes.some((shape) => shape.rings.some((ring) => insideRing(ring, at))); - this.cursor = inside && !onGrip ? 'move' : Theme.hoverCursor; + this.cursor = (inside || onLine) && !onGrip ? 'move' : Theme.hoverCursor; } /** The ring the pen is adding to, with its shape; null when no line is open or it has closed. */ diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index aa00e633..a2c3c1aa 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -301,6 +301,7 @@ export class SceneViewport extends FreeViewport { if (e.button === 2) this.state.endObjectPick('cancel'); return; } + if (e.button === 2 && this.corners.context(e)) return; if (e.button === 2 && e.target === this) { this.openEmptyMenu(e); return; @@ -450,11 +451,16 @@ export class SceneViewport extends FreeViewport { return this.sizer.info(); } - /** The corners of the polygon being edited, where they are drawn (test hook). */ - cornerInfo(): { objectId: string; corners: Vec2[] } | null { + /** The corners of the polygon being edited, where they are drawn, and the picked one (test hook). */ + cornerInfo(): { objectId: string; corners: Vec2[]; picked: number } | null { return this.corners.info(); } + /** Delete on a picked corner of the edited polygon (#288); false while none is picked. */ + removePickedCorner(): boolean { + return this.corners.removePicked(); + } + protected override isSelected(objectId: string): boolean { const s = this.state.selection; return s?.kind === 'object' && s.objectIds.includes(objectId); @@ -484,7 +490,7 @@ export class SceneViewport extends FreeViewport { return; } if (e.button === 2) { - this.openMenuUnder(e); + if (!this.corners.context(e)) this.openMenuUnder(e); return; } if (e.button !== 0) return; diff --git a/src/editor/viewport/polygonCorners.ts b/src/editor/viewport/polygonCorners.ts index 1bf035ef..e22c2bf3 100644 --- a/src/editor/viewport/polygonCorners.ts +++ b/src/editor/viewport/polygonCorners.ts @@ -7,8 +7,10 @@ import { colliderPoints, COLLIDER_POINTS_KEY } from '../../core/properties/colli import { pressCount, Theme } from '../../ui'; import type { DrawItem } from '../../viewport/items'; import { type View, worldToScreen } from '../../viewport/view'; -import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../polygonEdit'; +import { gripAt, MIN_CORNERS, withCornerAfter, withCornerMoved, withoutCorner } from '../polygonEdit'; import { FramePreview } from '../framePreview'; +import { deleteTakes, pickedIn, pointPress } from '../pointGestures'; +import { openPointMenu } from '../pointMenu'; import { type SceneViewHost, selectedItem } from './sceneViewHost'; /** Screen pixels around a polygon's corner or edge that grab it. */ @@ -19,9 +21,9 @@ const CORNER_SIZE = 8; const DRAG_THRESHOLD = 2; /** - * A corner of a polygon collider being dragged (#151) – one just put on an edge, too. Like a move - * drag it writes the model as a preview; `before` is the override that was there, put back on - * release so the command records the edit as one undo step. + * A corner of a polygon collider being dragged (#151). Like a move drag it writes the model as a + * preview; `before` is the override that was there, put back on release so the command records the + * edit as one undo step. */ interface CornerDrag { holderId: string; @@ -33,19 +35,26 @@ interface CornerDrag { toLocal: Mat; /** Global pointer position of the press. */ pointer: Vec2; - inserted: boolean; moved: boolean; } -/** Handles on the corners of the selected polygon collider, to move, add and take away (#151). */ +/** + * Handles on the corners of the selected polygon collider (#151): a press picks a corner and drags + * it, a double-click on an edge puts one there, and Delete or the corner's menu takes the picked one + * away (#288). + */ export class PolygonCornerEditor { private corner: CornerDrag | null = null; + /** The corner last pressed or put down; it stands only while its collider is the one edited. */ + private picked: { objectId: string; index: number } | null = null; /** The corners drawn for the edited polygon, in global pixels (test hook). */ private lastCorners: { objectId: string; corners: Vec2[] } | null = null; private readonly perFrame: FramePreview; constructor(private readonly host: SceneViewHost) { this.perFrame = new FramePreview(host.state, 'polygon-corner'); + // A corner is picked inside the selection; another selection starts without one. + host.state.on('selection', () => (this.picked = null)); } /** A corner is being dragged. */ @@ -59,42 +68,97 @@ export class PolygonCornerEditor { return item?.type === 'collider' && item.props.shape === 'polygon' ? item : null; } - /** - * A press on a corner or an edge of the edited polygon: a double-click takes the corner away, - * a press on an edge puts a corner there, and either can then be dragged. True when it was one. - */ - press(e: FederatedPointerEvent): boolean { - const state = this.host.state; + /** The picked corner of the edited polygon, -1 for none (test hook, and what is drawn filled). */ + pickedIndex(): number { + const item = this.editedPolygon(); + if (!item || this.picked?.objectId !== item.id) return -1; + return pickedIn(colliderPoints(item.props).length, this.picked.index); + } + + private pick(picked: { objectId: string; index: number } | null): void { + this.picked = picked; + this.host.redraw(); + } + + /** What lies under the pointer on the edited polygon, with all a press there needs. */ + private gripUnder(e: FederatedPointerEvent) { const item = this.editedPolygon(); const holder = this.host.holder(); - const ref = item && holder && objectRef(state.project, holder, item.id); - if (!item || !holder || !ref) return false; + const ref = item && holder && objectRef(this.host.state.project, holder, item.id); + if (!item || !holder || !ref) return null; const toLocal = invert(item.m); const at = applyPoint(toLocal, this.host.worldAt(e.global)); const points = colliderPoints(item.props); const tolerance = CORNER_GRIP / (this.host.scale() * (scaleOf(item.m) || 1)); const grip = gripAt(points, at, tolerance); - if (!grip) return false; + return grip ? { item, holder, ref, toLocal, points, grip } : null; + } + + /** + * A press on the edited polygon. On a corner it picks the corner, which can then be dragged; on an + * edge a click only drops the pick and a double-click puts a corner there, picked and in place + * until it is pressed again. True when it was either; any other press drops the pick. + */ + press(e: FederatedPointerEvent): boolean { + const under = this.gripUnder(e); + if (!under) { + if (this.picked) this.pick(null); + return false; + } + const { item, holder, ref, toLocal, points, grip } = under; e.stopPropagation(); - if (grip.kind === 'corner' && pressCount(e, `corner ${item.id} ${grip.index}`) === 2) { - const next = withoutCorner(points, grip.index); - if (next) state.execute(new SetOverrideCommand(holder.id, item.id, COLLIDER_POINTS_KEY, listOf(next))); - else state.setNotice('A polygon keeps at least three corners.'); + if (grip.kind === 'edge') { + if (pointPress('line', pressCount(e, `corner add ${item.id}`)) !== 'add') { + this.pick(null); + return true; + } + const next = withCornerAfter(points, grip.index, rounded(grip.at)); + this.host.state.execute(new SetOverrideCommand(holder.id, item.id, COLLIDER_POINTS_KEY, listOf(next))); + this.pick({ objectId: item.id, index: grip.index + 1 }); return true; } - const inserted = grip.kind === 'edge'; + this.pick({ objectId: item.id, index: grip.index }); this.corner = { holderId: holder.id, objectId: item.id, before: ref.object.overrides[COLLIDER_POINTS_KEY], - points: inserted ? withCornerAfter(points, grip.index, rounded(grip.at)) : points, - index: inserted ? grip.index + 1 : grip.index, + points, + index: grip.index, toLocal, pointer: { x: e.global.x, y: e.global.y }, - inserted, moved: false, }; - if (inserted) this.preview(); + return true; + } + + /** A right-click on a corner picks it and opens its menu; false when it was on none. */ + context(e: FederatedPointerEvent): boolean { + const under = this.gripUnder(e); + if (under?.grip.kind !== 'corner') return false; + e.stopPropagation(); + this.pick({ objectId: under.item.id, index: under.grip.index }); + openPointMenu( + { removable: under.points.length > MIN_CORNERS, remove: () => this.removePicked() }, + e.global.x, + e.global.y, + ); + return true; + } + + /** + * Delete: takes the picked corner away and leaves the collider itself alone. False while no + * corner is picked, so the key goes on to the selection. + */ + removePicked(): boolean { + const state = this.host.state; + const item = this.editedPolygon(); + const holder = this.host.holder(); + const index = this.pickedIndex(); + if (!item || !holder || deleteTakes(index >= 0) !== 'point') return false; + const next = withoutCorner(colliderPoints(item.props), index); + this.pick(null); + if (next) state.execute(new SetOverrideCommand(holder.id, item.id, COLLIDER_POINTS_KEY, listOf(next))); + else state.setNotice('A polygon keeps at least three corners.'); return true; } @@ -137,7 +201,7 @@ export class PolygonCornerEditor { const ref = holder && objectRef(state.project, holder, corner.objectId); if (!ref) return true; ref.setOverride(COLLIDER_POINTS_KEY, corner.before); - if (corner.moved || corner.inserted) + if (corner.moved) state.execute( new SetOverrideCommand(corner.holderId, corner.objectId, COLLIDER_POINTS_KEY, listOf(corner.points)), ); @@ -145,9 +209,9 @@ export class PolygonCornerEditor { return true; } - /** The corners of the polygon being edited, where they are drawn (test hook). */ - info(): { objectId: string; corners: Vec2[] } | null { - return this.lastCorners; + /** The corners of the polygon being edited, where they are drawn, and the picked one (test hook). */ + info(): { objectId: string; corners: Vec2[]; picked: number } | null { + return this.lastCorners && { ...this.lastCorners, picked: this.pickedIndex() }; } /** A handle on every corner of the polygon collider being edited. */ @@ -160,10 +224,12 @@ export class PolygonCornerEditor { const world = applyPoint(item.m, p); return worldToScreen(view, world.x, world.y); }); - for (const c of corners) + const picked = this.pickedIndex(); + // The picked corner wears the colours turned round, as a picked point of a curve does. + for (const [index, c] of corners.entries()) g.rect(c.x - half, c.y - half, CORNER_SIZE, CORNER_SIZE) - .fill(Theme.colors.accentText) - .stroke({ width: 1, color: Theme.colors.accent }); + .fill(index === picked ? Theme.colors.accent : Theme.colors.accentText) + .stroke({ width: 1, color: index === picked ? Theme.colors.accentText : Theme.colors.accent }); this.lastCorners = { objectId: item.id, corners: corners.map((c) => { diff --git a/tests/editor/point-gestures.test.ts b/tests/editor/point-gestures.test.ts new file mode 100644 index 00000000..55ac2e57 --- /dev/null +++ b/tests/editor/point-gestures.test.ts @@ -0,0 +1,481 @@ +/** + * 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 { + 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 (!('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); + }); +}); diff --git a/tools/e2e/42-shapes.mjs b/tools/e2e/42-shapes.mjs index 0a3bb2f6..99e23226 100644 --- a/tools/e2e/42-shapes.mjs +++ b/tools/e2e/42-shapes.mjs @@ -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 diff --git a/tools/e2e/47-vectorart.mjs b/tools/e2e/47-vectorart.mjs index 6a1aa025..0ba5c1e0 100644 --- a/tools/e2e/47-vectorart.mjs +++ b/tools/e2e/47-vectorart.mjs @@ -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. diff --git a/tools/e2e/53-curves.mjs b/tools/e2e/53-curves.mjs index 7596be3f..575e0429 100644 --- a/tools/e2e/53-curves.mjs +++ b/tools/e2e/53-curves.mjs @@ -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()');