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

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

Decided here:

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

Findings:

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

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

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

549 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The Curve tab's canvas (#247): the curve as a graph, `t` to the right and `v` up, with guides at
* 0 and 1. Points and handles are edited with the vector art gestures (see `curveView.ts` for how
* the curve is laid onto a picture for them), and a gesture writes the model as a preview and puts
* it back on release, so one drag is one undo step.
*
* The line is drawn from `curveValue`, not from the Bezier segments: what the tab shows is exactly
* what a key or a `Lerp` reads. A dot runs along it as a preview, and a second one beside the graph
* moves only up and down – the motion the curve gives a value.
*/
import { type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
import { SetCurvePointsCommand } from '../../core/commands/curveCommands';
import type { CurveAsset, VectorPoint, VectorRing } from '../../core/model/Asset';
import { curveExtent, curveValue, sanitizedCurve } from '../../core/model/curve';
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,
ringGripAt,
withHandleMoved,
withoutPoint,
withPointKind,
withPointOnSegment,
withSmoothToggled,
} from '../polygonEdit';
import type { EditorState } from '../state';
import { SheetCanvas } from '../texture/SheetCanvas';
import { fitView, imageToLocal, localToImage } from '../texture/sheetView';
import {
curveFrame,
curveToImage,
fromImage,
imageToCurve,
isCurveEnd,
previewSecondsAt,
previewShare,
shareAtValue,
snappedToGrid,
toImage,
withCurvePointAt,
withCurvePointMoved,
withCurvePointSet,
} from './curveView';
/** Steps the drawn line is walked in; a bounce needs this many to keep its corners. */
const LINE_STEPS = 240;
/** Where the preview's second dot runs, in units of `t` right of the graph. */
const BAR_AT = 1.08;
const DOT_RADIUS = 5;
/** Screen pixels around the playhead's knob and the dot beside the graph that grab them. */
const SCRUB_GRIP = DOT_RADIUS + 3;
/** A gesture in progress. `before` is what the curve read when it began – the undo step's other side. */
interface CurveDrag {
kind: 'point' | 'handle';
before: VectorPoint[];
ring: VectorRing;
points: VectorPoint[];
index: number;
side: 'in' | 'out';
pointer: Vec2;
moved: boolean;
label: string;
}
let instances = 0;
export class CurveCanvas extends SheetCanvas<CurveAsset> {
private readonly guides = new Graphics();
private readonly line = new Graphics();
private readonly overlay = new Graphics();
private readonly labels = ['0', '1', '0', '1'].map((text) => new Text({ text, style: textStyle('muted') }));
private drag: CurveDrag | null = null;
/** The point last pressed or put down – the one the header's point type is about; -1 for none. */
private picked = -1;
onPointPicked: () => void = () => {};
/** Seconds the preview has run; it only moves while `previewing` is on and no scrub holds it. */
private previewTime = 0;
private previewOn = false;
/** The preview dragged by its knob under the graph (`t`) or by its dot beside it (`v`). */
private scrub: 'playhead' | 'rail' | null = null;
private snap = false;
/** The curve before the header's fields started moving the picked point – their undo step's other side. */
private typed: VectorPoint[] | null = null;
private readonly typedPreview: FramePreview;
private readonly frameKey = `curve:${(instances += 1)}`;
constructor(private readonly state: EditorState) {
super();
this.typedPreview = new FramePreview(state, 'curve-field');
this.content.addChild(this.guides, this.line, this.overlay, ...this.labels);
for (const label of this.labels) label.eventMode = 'none';
state.on('project', () => this.redraw());
state.on('load', () => {
this.needsFit = true;
this.pick(-1);
this.redraw();
});
}
show(assetId: string): void {
if (this.assetId === assetId) return;
this.assetId = assetId;
this.needsFit = true;
this.typed = null;
this.typedPreview.cancel();
this.pick(-1);
this.redraw();
}
/** Moves the preview on; only called while the tab is visible. */
tick(dt: number): void {
if (!this.previewOn || this.scrub || !this.asset()) return;
this.previewTime += dt;
this.redraw();
}
get previewing(): boolean {
return this.previewOn;
}
setPreviewing(on: boolean): void {
this.previewOn = on;
this.redraw();
}
setSnapping(on: boolean): void {
this.snap = on;
}
get snapping(): boolean {
return this.snap;
}
/** Where the preview stands: the share of time, the value the curve gives it, its two dots and its knob. */
previewState(): { t: number; v: number; dot: Vec2; bar: Vec2; knob: Vec2 } | null {
const asset = this.asset();
if (!asset) return null;
const t = previewShare(this.previewTime);
const v = curveValue(asset.points, t);
const origin = this.getGlobalPosition();
const knob = this.knobAt(asset.points, t);
return {
t,
v,
dot: this.graphToScreen(t, v),
bar: this.graphToScreen(BAR_AT, v),
knob: { x: origin.x + knob.x, y: origin.y + knob.y },
};
}
/** For the check scripts: stops the preview at `seconds` into its pass. */
setPreviewTime(seconds: number): void {
this.previewTime = seconds;
this.redraw();
}
/** Where a place on the graph sits on screen – the check scripts aim with it. */
graphToScreen(t: number, v: number): Vec2 {
const at = toImage({ x: t, y: v });
return this.imageToScreen(at.x, at.y);
}
get pickedIndex(): number {
return this.picked;
}
/** The type of the picked point; null while none is picked or it has gone since (an undo). */
get pickedKind(): PointKind | null {
const point = this.asset()?.points[this.picked];
return point ? pointKind(point) : null;
}
/** The picked point's place, `t` and `v`; null while none is picked. */
get pickedPlace(): Vec2 | null {
const point = this.asset()?.points[this.picked];
return point ? { x: point.x, y: point.y } : null;
}
/** Whether the picked point is an end, whose `t` is fixed. */
get pickedIsEnd(): boolean {
const asset = this.asset();
return asset !== null && isCurveEnd(curveToImage(asset.points), this.picked);
}
/**
* Puts the picked point at `to` – the header's `t` and `v` fields. Until `commit` it only shows
* the change, so a scrub on a field is one undo step, as a drag on the canvas is. The curve is
* written on every input, the preview event waits for the frame (#274).
*/
movePicked(to: Vec2, commit: boolean): void {
const asset = this.asset();
if (!asset || !asset.points[this.picked]) return;
const before = (this.typed ??= asset.points);
const points = sanitizedCurve(withCurvePointSet(before, this.picked, to));
if (!commit) {
asset.points = points;
this.typedPreview.request();
return;
}
this.typedPreview.cancel();
asset.points = before;
this.typed = null;
if (SetCurvePointsCommand.changes(asset, points))
this.state.execute(new SetCurvePointsCommand(asset.id, points, 'Move point'));
else this.state.preview();
}
setPickedKind(kind: PointKind): void {
const asset = this.asset();
if (!asset || !asset.points[this.picked]) return;
const points = imageToCurve(withPointKind(curveToImage(asset.points), this.picked, kind));
if (SetCurvePointsCommand.changes(asset, points))
this.state.execute(new SetCurvePointsCommand(asset.id, points, 'Point type'));
}
/** Replaces the whole curve – what a preset does. */
replace(points: readonly VectorPoint[], label: string): void {
const asset = this.asset();
if (!asset || !SetCurvePointsCommand.changes(asset, points)) return;
this.pick(-1);
this.state.execute(new SetCurvePointsCommand(asset.id, points, label));
}
private pick(index: number): void {
if (this.picked === index) return;
this.picked = index;
this.onPointPicked();
}
private asset(): CurveAsset | null {
return (this.assetId && findAssetOf(this.state.project, this.assetId, 'curve')) || null;
}
/** 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();
const picked = asset ? pickedIn(asset.points.length, this.picked) : -1;
if (!asset || deleteTakes(picked >= 0) !== 'point') return false;
this.removePoint(asset, picked);
return true;
}
private removePoint(asset: CurveAsset, index: number): void {
const ring = curveToImage(asset.points);
const next = isCurveEnd(ring, index) ? null : withoutPoint(ring, index);
if (!next) return;
this.pick(-1);
this.state.execute(new SetCurvePointsCommand(asset.id, imageToCurve(next), 'Remove point'));
}
protected pressTarget(): CurveAsset | null {
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 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();
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') {
this.pick(grip.index);
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, 'Move point');
return;
}
if (grip?.kind === 'handle') {
this.pick(grip.index);
this.startDrag(asset, ring, grip.index, 'handle', grip.side, local, 'Bend curve');
return;
}
const scrub = grip?.kind === 'segment' ? null : this.scrubAt(asset.points, local);
if (scrub) {
this.scrub = scrub;
this.scrubTo(asset.points, local);
return;
}
const spot = grip?.kind === 'segment' ? 'line' : 'free';
const put =
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.redraw();
}
private startDrag(
asset: CurveAsset,
ring: VectorRing,
index: number,
kind: CurveDrag['kind'],
side: 'in' | 'out',
pointer: Vec2,
label: string,
): void {
this.drag = {
kind,
before: asset.points,
ring,
points: sanitizedCurve(imageToCurve(ring)),
index,
side,
pointer: { x: pointer.x, y: pointer.y },
moved: false,
label,
};
this.redraw();
}
/** The preview's knob under the graph or its dot beside it, if `local` is on one. */
private scrubAt(points: readonly VectorPoint[], local: Vec2): 'playhead' | 'rail' | null {
const t = previewShare(this.previewTime);
const knob = this.knobAt(points, t);
if (Math.hypot(local.x - knob.x, local.y - knob.y) <= SCRUB_GRIP) return 'playhead';
const bar = this.local(BAR_AT, curveValue(points, t));
if (Math.hypot(local.x - bar.x, local.y - bar.y) <= SCRUB_GRIP) return 'rail';
return null;
}
/** The knob sideways sets `t`; the dot up and down jumps to the first `t` the curve reaches that value. */
private scrubTo(points: readonly VectorPoint[], local: Vec2): void {
const on = fromImage(localToImage(this.view, local.x, local.y));
const t = this.scrub === 'rail' ? shareAtValue(points, on.y) : on.x;
this.previewTime = previewSecondsAt(t);
this.redraw();
}
protected move(local: Vec2): void {
const drag = this.drag;
const asset = this.asset();
if (this.scrub && asset) {
this.scrubTo(asset.points, local);
return;
}
if (!drag) {
this.updateCursor(local);
return;
}
if (!drag.moved && Math.hypot(local.x - drag.pointer.x, local.y - drag.pointer.y) < DRAG_THRESHOLD)
return;
drag.moved = true;
const to = this.snapped(localToImage(this.view, local.x, local.y));
drag.ring =
drag.kind === 'handle'
? withHandleMoved(drag.ring, drag.index, drag.side, to)
: withCurvePointMoved(drag.ring, drag.index, to);
drag.points = sanitizedCurve(imageToCurve(drag.ring));
this.preview();
}
/** Once per frame, not once per pointer move – the reason is the one `VectorArtCanvas.preview` gives. */
private preview(): void {
onFrame(this.frameKey, () => {
const asset = this.asset();
if (this.destroyed || !this.drag || !asset) return;
asset.points = this.drag.points;
this.state.preview();
});
}
protected release(): void {
this.scrub = null;
const drag = this.drag;
this.drag = null;
cancelFrameWork(this.frameKey);
const asset = this.asset();
if (!drag || !asset) return;
asset.points = drag.before;
if (drag.moved && SetCurvePointsCommand.changes(asset, drag.points))
this.state.execute(new SetCurvePointsCommand(asset.id, drag.points, drag.label));
else this.state.preview();
}
private updateCursor(local: Vec2): void {
const asset = this.asset();
if (!asset) {
this.cursor = 'default';
return;
}
const at = localToImage(this.view, local.x, local.y);
const grip = ringGripAt(curveToImage(asset.points), at, POINT_SIZE / this.view.zoom);
this.cursor = grip || this.scrubAt(asset.points, local) ? Theme.hoverCursor : 'crosshair';
}
/** On the grid while snapping is on. */
private snapped(at: Vec2): Vec2 {
return this.snap ? snappedToGrid(at) : at;
}
override redraw(): void {
this.drawGround();
const asset = this.asset();
if (asset && this.needsFit && this.w > 0 && this.h > 0) {
const frame = curveFrame(asset.points);
const view = fitView(frame.w, frame.h, this.w, this.h);
this.view = { ...view, panX: view.panX - frame.x * view.zoom, panY: view.panY - frame.y * view.zoom };
this.needsFit = false;
}
this.checker.clear();
this.guides.clear();
this.line.clear();
this.overlay.clear();
for (const label of this.labels) label.visible = asset !== null;
if (asset) {
this.drawGuides();
this.drawLine(asset.points);
// Under the points: resting at the end, the dot would hide the last one.
this.drawPreview(asset.points);
this.drawPoints(asset.points);
}
this.syncHint(asset);
}
private local(t: number, v: number): Vec2 {
const at = toImage({ x: t, y: v });
return imageToLocal(this.view, at.x, at.y);
}
/** The unit square, its sides at 0 and 1 carried across the whole view, and their numbers. */
private drawGuides(): void {
const origin = this.local(0, 0);
const corner = this.local(1, 1);
this.guides
.rect(origin.x, corner.y, corner.x - origin.x, origin.y - corner.y)
.fill({ color: Theme.colors.panel, alpha: 0.5 });
for (const y of [origin.y, corner.y]) this.guides.moveTo(0, y).lineTo(this.w, y);
for (const x of [origin.x, corner.x]) this.guides.moveTo(x, 0).lineTo(x, this.h);
this.guides.stroke({ width: 1, color: Theme.colors.border });
const [v0, v1, t0, t1] = this.labels as [Text, Text, Text, Text];
const gap = Theme.gap;
v0.position.set(origin.x - v0.width - gap, origin.y - v0.height / 2);
v1.position.set(origin.x - v1.width - gap, corner.y - v1.height / 2);
t0.position.set(origin.x - t0.width / 2, origin.y + gap);
t1.position.set(corner.x - t1.width / 2, origin.y + gap);
}
private drawLine(points: readonly VectorPoint[]): void {
const walked: number[] = [];
for (let step = 0; step <= LINE_STEPS; step++) {
const t = step / LINE_STEPS;
const at = this.local(t, curveValue(points, t));
walked.push(at.x, at.y);
}
this.line.poly(walked, false).stroke({ width: 2, color: Theme.colors.accent });
}
/** Every point with its handles, in widget pixels so they keep their size. */
private drawPoints(points: readonly VectorPoint[]): void {
for (const [index, point] of points.entries())
drawPointGrip(this.overlay, point, index === this.picked, (t, v) => this.local(t, v));
}
/** The playhead's knob: under the graph and its `t` numbers, below whatever the curve reaches. */
private knobAt(points: readonly VectorPoint[], t: number): Vec2 {
const floor = this.local(t, Math.min(0, curveExtent(points).min));
const numbers = this.labels[2]?.height ?? 0;
return { x: floor.x, y: floor.y + Theme.gap * 2 + numbers + DOT_RADIUS };
}
/**
* A dot on the line at the preview's `t`, its twin on a rail beside the graph, and a playhead
* through it with a knob underneath – knob and twin can be dragged to put the preview anywhere.
*/
private drawPreview(points: readonly VectorPoint[]): void {
const t = previewShare(this.previewTime);
const v = curveValue(points, t);
const dot = this.local(t, v);
const bar = this.local(BAR_AT, v);
const top = this.local(BAR_AT, 1);
const bottom = this.local(BAR_AT, 0);
const knob = this.knobAt(points, t);
this.overlay
.moveTo(knob.x, 0)
.lineTo(knob.x, knob.y)
.moveTo(dot.x, dot.y)
.lineTo(bar.x, bar.y)
.stroke({ width: 1, color: Theme.colors.hot, alpha: 0.4 });
this.overlay.circle(knob.x, knob.y, DOT_RADIUS).fill(Theme.colors.hot);
this.overlay
.moveTo(top.x, top.y)
.lineTo(bottom.x, bottom.y)
.stroke({ width: 2, color: Theme.colors.border });
for (const at of [dot, bar]) this.overlay.circle(at.x, at.y, DOT_RADIUS).fill(Theme.colors.hot);
}
private syncHint(asset: CurveAsset | null): void {
this.hint.text = asset
? '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);
}
}