Add points by double-click and delete the picked one (#288)
Four editors edit points, and all of them put a point down on a single press on the line and took one away on a double-click. A stray click added a point, and there was no way to see which point a key would hit. All four now work the same way: the Curve tab, the Vector art tab, a polygon collider in the scene view and a frame's collision outline in the Texture tab. - A double-click on a line, edge or segment puts a point there, in the Curve tab on free space too. The point is picked and stays in place. It can be dragged with the next press, as answered in the issue. - A click on a point picks it, and it is drawn filled. A double-click on a point takes nothing away any more. - Delete or Backspace takes the picked point away. Without a picked point the key removes the shape, the object or the outline as before. - A right-click on a point picks it and opens a menu with Delete point. In the Curve and Vector art tab the four point types stand below it. Alt-click stays. - A polygon keeps three corners and a curve keeps its ends. The entry is greyed out then. - The pick ends with a click beside the points, with another object, shape or frame, and after the point is gone. - editor/pointGestures.ts holds the rule as a pure function, and editor/pointMenu.ts builds the menu for all four. Decided here: - A single click on a line no longer adds anything. In Vector art it drags the shape, on a polygon it drops the pick, and in the Texture tab it makes that outline the active one and drops the pick. - In the Texture tab a click inside the frame still appends a corner, and that corner is picked. Delete takes the last corner first. To remove the whole outline, click one of its edges before. - The point the Pen put down last is picked, so Delete takes it and not the shape. - Delete hits the picked point also when the tab has lost the keyboard focus, after a click in the shape list or after the menu. - A vector shape that loses its last point is removed whole. Before, an empty shape stayed in the list. Findings: - Fixed: the new hint line in the Texture tab was cut off at the edge. The shorter text was not looked at in a picture again. - Not fixed: in the scene view the collider's name label covers a corner that sits near the centre. That was so before. Tests: - Gate: 200 files, 2967 tests (2944 + 23), exit 0 - e2e, full run: 1629 checks passed (1625 + 4), exit 0 - Smokes: 24 / 19 / 21, exit 0 - After the full run an unused parameter and a comment were tidied. For that final state the gate and e2e 42,47,53 (105 checks) ran again, the full run did not. - 23 unit tests in tests/editor/point-gestures.test.ts, for the rule and for each of the four editors - Four e2e checks with a real mouse, two in module 42 and one each in 47 and 53. The existing checks for adding and removing a point use the new gestures. - 20 unit counter-probes all fail tests, run on stein2 - Two browser counter-probes fail, the build went through both times. Without the Delete wiring the key removes the collider. Without the right-click hook the menu is missing, and that run stops in module 42, so 47 and 53 were not reached with it. - Three screenshots looked at, the picked point is filled. None of the Vector art tab. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1aa824ecb4
commit
0f85476b93
19 files changed
+1180
-168
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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<PointKind, string> = {
|
||||
export const POINT_KIND_LABELS: Record<PointKind, string> = {
|
||||
straight: 'Straight',
|
||||
corner: 'Corner',
|
||||
smooth: 'Smooth',
|
||||
|
||||
@@ -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<CurveAsset> {
|
||||
|
||||
/** 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<CurveAsset> {
|
||||
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<CurveAsset> {
|
||||
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<CurveAsset> {
|
||||
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<CurveAsset> {
|
||||
kind: CurveDrag['kind'],
|
||||
side: 'in' | 'out',
|
||||
pointer: Vec2,
|
||||
moved: boolean,
|
||||
label: string,
|
||||
): void {
|
||||
this.drag = {
|
||||
@@ -325,7 +356,7 @@ export class CurveCanvas extends SheetCanvas<CurveAsset> {
|
||||
index,
|
||||
side,
|
||||
pointer: { x: pointer.x, y: pointer.y },
|
||||
moved,
|
||||
moved: false,
|
||||
label,
|
||||
};
|
||||
this.redraw();
|
||||
@@ -509,7 +540,7 @@ export class CurveCanvas extends SheetCanvas<CurveAsset> {
|
||||
|
||||
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);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -44,6 +44,8 @@ export abstract class SheetCanvas<A> 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<A> 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 });
|
||||
|
||||
@@ -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<TextureAsset> {
|
||||
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<TextureAsset> {
|
||||
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<TextureAsset> {
|
||||
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<TextureAsset> {
|
||||
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<TextureAsset> {
|
||||
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<TextureAsset> {
|
||||
): 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<TextureAsset> {
|
||||
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<TextureAsset> {
|
||||
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<TextureAsset> {
|
||||
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<TextureAsset> {
|
||||
? '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.'
|
||||
|
||||
@@ -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<VectorArtAsset> {
|
||||
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<VectorArtAsset> {
|
||||
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<VectorArtAsset> {
|
||||
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<VectorArtAsset> {
|
||||
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<VectorArtAsset> {
|
||||
|
||||
/**
|
||||
* 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<VectorArtAsset> {
|
||||
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<VectorArtAsset> {
|
||||
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<VectorArtAsset> {
|
||||
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<VectorArtAsset> {
|
||||
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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user