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()');