Type point values, snap and scrub the preview in the curve tab (#248)
game-maker / gate (push) Successful in 3m23s
game-maker / suite (push) Successful in 10m9s

Building a curve by mouse alone was fiddly: there was no way to put a
point on an exact value, no grid, and the running preview could not be
stopped at a chosen moment. The hand cursor over points also looked
like a link instead of a drawing tool.

- Number fields t and v in the header show the selected point. Typing
  or dragging on them goes through the same rules as a mouse drag, so
  points never overtake and handles stay clamped. One undo step each.
  t is greyed out on the two end points.
- The magnet button from Vector art snaps points and handles to a
  0.05 grid in t and v, when placing on free space and when dragging.
- Points and handles keep the default arrow in the curve tab and in
  Vector art. The pixel art palette dropped pointer as well, which is
  beyond the issue. Collider corners never had it.
- The preview now starts off. A vertical time line with a knob under
  the t axis sets t when dragged sideways. Dragging the dot on the
  rail jumps to the first t where the curve reaches that value, or to
  the closest one if it never does.
- The rules are pure functions in curveView.ts (withCurvePointSet,
  snappedToGrid, shareAtValue, previewSecondsAt), the canvas only
  calls them.

Tests:
- ./check.sh: 180 files, 2616 tests, CHECK EXIT 0
- GM_STEPS=1,47,53: ALL PASSED, 87 checks
- 4 new unit tests in curve-view.test.ts, 4 new e2e checks in 53
  (53h-53k) and 1 in 47, all with real mouse and keyboard
- Counter-probes bite: without "first t" 1 unit test fails, with
  pointer, no snap, no field commit, no scrub or preview on 5 browser
  checks fail, pointer in Vector art fails the new 47 check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-27 16:45:53 +02:00
1 parent 393b8ec7f9
commit a5ae21481b
11 files changed
+461 -43

No files matched your search

+122 -11
View File
@@ -11,7 +11,7 @@
import { type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
import { SetCurvePointsCommand } from '../../core/commands/curveCommands';
import type { CurveAsset, VectorPoint, VectorRing } from '../../core/model/Asset';
import { curveValue, sanitizedCurve } from '../../core/model/curve';
import { curveExtent, curveValue, sanitizedCurve } from '../../core/model/curve';
import { findAssetOf } from '../../core/model/Project';
import type { Vec2 } from '../../core/model/Properties';
import { handleAt } from '../../core/model/vectorart';
@@ -32,12 +32,17 @@ import { fitView, imageToLocal, localToImage } from '../texture/sheetView';
import {
curveFrame,
curveToImage,
fromImage,
imageToCurve,
isCurveEnd,
previewSecondsAt,
previewShare,
shareAtValue,
snappedToGrid,
toImage,
withCurvePointAt,
withCurvePointMoved,
withCurvePointSet,
} from './curveView';
/** Side of a point handle in screen pixels – constant, so a point stays grabbable at any zoom. */
@@ -49,6 +54,8 @@ 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 {
@@ -74,9 +81,14 @@ export class CurveCanvas extends SheetCanvas<CurveAsset> {
/** 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. */
/** Seconds the preview has run; it only moves while `previewing` is on and no scrub holds it. */
private previewTime = 0;
private previewOn = true;
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 frameKey = `curve:${(instances += 1)}`;
constructor(private readonly state: EditorState) {
@@ -95,13 +107,14 @@ export class CurveCanvas extends SheetCanvas<CurveAsset> {
if (this.assetId === assetId) return;
this.assetId = assetId;
this.needsFit = true;
this.typed = null;
this.pick(-1);
this.redraw();
}
/** Moves the preview on; only called while the tab is visible. */
tick(dt: number): void {
if (!this.previewOn || !this.asset()) return;
if (!this.previewOn || this.scrub || !this.asset()) return;
this.previewTime += dt;
this.redraw();
}
@@ -115,13 +128,29 @@ export class CurveCanvas extends SheetCanvas<CurveAsset> {
this.redraw();
}
/** Where the preview stands: the share of time, the value the curve gives it, and its two dots. */
previewState(): { t: number; v: number; dot: Vec2; bar: Vec2 } | null {
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);
return { t, v, dot: this.graphToScreen(t, v), bar: this.graphToScreen(BAR_AT, v) };
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. */
@@ -146,6 +175,39 @@ export class CurveCanvas extends SheetCanvas<CurveAsset> {
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.
*/
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.state.preview();
return;
}
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;
@@ -223,10 +285,16 @@ export class CurveCanvas extends SheetCanvas<CurveAsset> {
this.startDrag(asset, ring, grip.index, 'handle', grip.side, local, false, '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 put =
grip?.kind === 'segment'
? { ring: withPointOnSegment(ring, grip.index, grip.t), index: grip.index + 1 }
: withCurvePointAt(ring, at);
: withCurvePointAt(ring, this.snapped(at));
if (!put) {
this.pick(-1);
this.redraw();
@@ -261,8 +329,31 @@ export class CurveCanvas extends SheetCanvas<CurveAsset> {
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;
@@ -270,7 +361,7 @@ export class CurveCanvas extends SheetCanvas<CurveAsset> {
if (!drag.moved && Math.hypot(local.x - drag.pointer.x, local.y - drag.pointer.y) < DRAG_THRESHOLD)
return;
drag.moved = true;
const to = localToImage(this.view, local.x, local.y);
const to = this.snapped(localToImage(this.view, local.x, local.y));
drag.ring =
drag.kind === 'handle'
? withHandleMoved(drag.ring, drag.index, drag.side, to)
@@ -290,6 +381,7 @@ export class CurveCanvas extends SheetCanvas<CurveAsset> {
}
protected release(): void {
this.scrub = null;
const drag = this.drag;
this.drag = null;
cancelFrameWork(this.frameKey);
@@ -309,7 +401,12 @@ export class CurveCanvas extends SheetCanvas<CurveAsset> {
}
const at = localToImage(this.view, local.x, local.y);
const grip = ringGripAt(curveToImage(asset.points), at, POINT_SIZE / this.view.zoom);
this.cursor = grip ? 'pointer' : 'crosshair';
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 {
@@ -395,7 +492,17 @@ export class CurveCanvas extends SheetCanvas<CurveAsset> {
}
}
/** A dot on the line at the preview's `t`, and its twin on a rail beside the graph. */
/** 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);
@@ -403,10 +510,14 @@ export class CurveCanvas extends SheetCanvas<CurveAsset> {
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)
+44 -4
View File
@@ -1,7 +1,8 @@
import type { Asset, CurveAsset } from '../../core/model/Asset';
import { curvePresets } from '../../core/model/curvePresets';
import { Button, Dropdown, Menu, type MenuEntry } from '../../ui';
import { Button, Dropdown, Label, Menu, type MenuEntry, NumberField } from '../../ui';
import { AssetTabEditor } from '../AssetTabEditor';
import { SnapToggle } from '../graph/SnapToggle';
import { POINT_KINDS, type PointKind } from '../polygonEdit';
import type { EditorState } from '../state';
import { CurveCanvas } from './CurveCanvas';
@@ -9,6 +10,7 @@ import { CurveCanvas } from './CurveCanvas';
const INFO_WIDTH = 90;
/** Fixed, so the header does not shift when "Point: Symmetric" becomes "Point: Corner". */
const POINT_WIDTH = 150;
const PLACE_WIDTH = 64;
const POINT_KIND_LABELS: Record<PointKind, string> = {
straight: 'Straight',
@@ -23,26 +25,58 @@ function pointLabel(kind: PointKind | null): string {
/**
* The Curve tab (#247): the canvas edits the curve, the header starts it over from a built-in ease,
* sets the picked point's type as in the Vector art tab, and runs the preview or holds it.
* sets the picked point's type as in the Vector art tab and its place by number, snaps, and runs the
* preview or holds it.
*/
export class CurveEditor extends AssetTabEditor<CurveAsset, CurveCanvas> {
/** Public for the check scripts. */
readonly presetButton = new Dropdown('Preset');
readonly pointButton = new Dropdown(pointLabel(null), { width: POINT_WIDTH });
readonly tField: NumberField;
readonly vField: NumberField;
readonly snap = new SnapToggle(() => this.canvas);
readonly previewButton: Button;
constructor(state: EditorState) {
super(state, { infoWidth: INFO_WIDTH });
this.presetButton.onClick = () => this.openPresetMenu();
this.pointButton.onClick = () => this.openPointMenu();
this.tField = this.placeField((t, commit) => this.movePicked(t, null, commit));
this.vField = this.placeField((v, commit) => this.movePicked(null, v, commit));
this.previewButton = new Button('Preview', {
toggle: true,
onClick: () => this.setPreviewing(!this.canvas.previewing),
});
const canvas = new CurveCanvas(state);
canvas.onPointPicked = () => this.syncPoint();
this.mount(canvas, [this.presetButton, this.pointButton, this.previewButton]);
this.setPreviewing(true);
this.mount(canvas, [
this.presetButton,
this.pointButton,
new Label('t', { style: 'muted' }),
this.tField,
new Label('v', { style: 'muted' }),
this.vField,
...this.snap.widgets,
this.previewButton,
]);
this.setPreviewing(false);
}
private placeField(set: (value: number, commit: boolean) => void): NumberField {
return new NumberField(0, {
width: PLACE_WIDTH,
step: 0.01,
decimals: 3,
onInput: (value) => set(value, false),
onChange: (value) => set(value, true),
});
}
/** One of the picked point's two numbers changed; the other stays where the point is. */
private movePicked(t: number | null, v: number | null, commit: boolean): void {
const place = this.canvas.pickedPlace;
if (!place) return;
this.canvas.movePicked({ x: t ?? place.x, y: v ?? place.y }, commit);
}
/** Only called while the tab is visible. */
@@ -88,6 +122,11 @@ export class CurveEditor extends AssetTabEditor<CurveAsset, CurveCanvas> {
const kind = this.canvas.pickedKind;
this.pointButton.setText(pointLabel(kind));
this.pointButton.setEnabled(kind !== null);
const place = this.canvas.pickedPlace;
this.tField.setNumber(place?.x ?? 0);
this.vField.setNumber(place?.y ?? 0);
this.tField.setEnabled(place !== null && !this.canvas.pickedIsEnd);
this.vField.setEnabled(place !== null);
}
override selectedId(): string {
@@ -107,6 +146,7 @@ export class CurveEditor extends AssetTabEditor<CurveAsset, CurveCanvas> {
this.fitButton.setEnabled(asset !== null);
this.presetButton.setEnabled(asset !== null);
this.previewButton.setEnabled(asset !== null);
this.snap.setEnabled(asset !== null);
this.syncPoint();
}
}
+52 -6
View File
@@ -7,13 +7,20 @@
* and `imageToCurve` reads the result back.
*/
import type { VectorPoint, VectorRing } from '../../core/model/Asset';
import { curveExtent } from '../../core/model/curve';
import { curveExtent, curveValue } from '../../core/model/curve';
import type { Vec2 } from '../../core/model/Properties';
import { cloneVectorPoint } from '../../core/model/vectorart';
import { roundTo } from '../../ui/math';
/** Picture pixels per unit of `t` and of `v`. */
export const CURVE_SCALE = 256;
/** The grid points and handles land on while snapping is on, in units of `t` and `v`. */
export const CURVE_SNAP = 0.05;
/** Steps the curve is walked in to find where it reaches a value – a pixel or finer at any fit. */
const SEEK_STEPS = 1000;
/** Room around the curve when it is fitted, in units of `t`. */
const FIT_MARGIN = 0.12;
@@ -69,16 +76,50 @@ export function curveFrame(points: readonly VectorPoint[]): { x: number; y: numb
* never overtake one another, and the two ends move only up and down.
*/
export function withCurvePointMoved(ring: VectorRing, index: number, to: Vec2): VectorRing {
const last = ring.points.length - 1;
const points = ring.points.map((point, i) => {
return { closed: false, points: withCurvePointSet(ring.points, index, to) };
}
/**
* The same rule on the curve itself, `t` and `v` as typed into the header's fields: `t` points
* right in both, which is all the rule needs.
*/
export function withCurvePointSet(points: readonly VectorPoint[], index: number, to: Vec2): VectorPoint[] {
const last = points.length - 1;
return points.map((point, i) => {
const copy = cloneVectorPoint(point);
if (i !== index) return copy;
const before = ring.points[i - 1];
const after = ring.points[i + 1];
const before = points[i - 1];
const after = points[i + 1];
const x = i === 0 || i === last ? point.x : Math.min(Math.max(to.x, before?.x ?? to.x), after?.x ?? to.x);
return { ...copy, x, y: to.y };
});
return { closed: false, points };
}
/** A picture place moved onto the snapping grid, which runs in `t` and `v`, not in pixels. */
export function snappedToGrid(at: Vec2): Vec2 {
const on = fromImage(at);
const step = (n: number) => roundTo(Math.round(n / CURVE_SNAP) * CURVE_SNAP, 6);
return toImage({ x: step(on.x), y: step(on.y) });
}
/**
* The first `t` at which the curve reaches `v` – where the preview jumps when its dot beside the
* graph is dragged. A value the curve never reaches gives the `t` where it comes closest.
*/
export function shareAtValue(points: readonly VectorPoint[], v: number): number {
let previous = curveValue(points, 0);
let closest = { t: 0, gap: Math.abs(previous - v) };
for (let step = 1; step <= SEEK_STEPS; step++) {
const t = step / SEEK_STEPS;
const here = curveValue(points, t);
if ((previous - v) * (here - v) <= 0) {
const share = here === previous ? 0 : (v - previous) / (here - previous);
return (step - 1 + share) / SEEK_STEPS;
}
if (Math.abs(here - v) < closest.gap) closest = { t, gap: Math.abs(here - v) };
previous = here;
}
return closest.t;
}
/**
@@ -107,3 +148,8 @@ export function previewShare(seconds: number): number {
const into = ((seconds % cycle) + cycle) % cycle;
return Math.min(into / PREVIEW_RUN, 1);
}
/** The time into a pass at which the preview stands at `t` – where a drag on it puts the preview. */
export function previewSecondsAt(t: number): number {
return Math.min(Math.max(t, 0), 1) * PREVIEW_RUN;
}
+10
View File
@@ -28,6 +28,16 @@ export function curveHooks({ state, editor }: HookContext) {
...centreOf(curveEditor.previewButton),
toggled: curveEditor.previewButton.toggled,
}),
snapButton: () => ({
...centreOf(curveEditor.snap.button),
toggled: curveEditor.snap.button.toggled,
}),
/** The picked point's `t` and `v` fields as shown. */
placeField: (axis: 't' | 'v') => {
const field = axis === 't' ? curveEditor.tField : curveEditor.vField;
return { ...centreOf(field), text: field.value, enabled: field.enabled };
},
cursor: () => curveEditor.canvas.cursor,
/** The preview as drawn: the share of time, the value, and where its two dots are. */
preview: () => curveEditor.canvas.previewState(),
setPreviewTime: (seconds: number) => curveEditor.canvas.setPreviewTime(seconds),
+1
View File
@@ -14,6 +14,7 @@ export function vectorArtHooks({ state, editor }: HookContext) {
rect: () => vectorArtEditor.canvasRect(),
tool: () => vectorArtEditor.canvas.currentTool,
snapping: () => vectorArtEditor.canvas.snapping,
cursor: () => vectorArtEditor.canvas.cursor,
snapButton: () => snapButton(vectorArtEditor.snap),
/** Whether the pen has a line open that the next click would add a point to. */
drawing: () => vectorArtEditor.canvas.drawing,
+1 -1
View File
@@ -34,7 +34,7 @@ export class PaletteGrid extends Widget {
constructor() {
super();
this.addChild(this.swatches);
this.cursor = 'pointer';
this.cursor = Theme.hoverCursor;
this.on('pointerdown', (e: FederatedPointerEvent) => {
const local = this.toLocal(e.global);
const { columns, cell } = this.grid();
+3 -10
View File
@@ -629,9 +629,10 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
this.state.execute(new SetVectorShapeStyleCommand(this.assetId, shape.id, style, 'Move paint handle'));
}
/** Points and handles keep the plain arrow, as every other clickable does (`Theme.hoverCursor`). */
private updateCursor(local: Vec2): void {
const asset = this.asset();
if (!asset) {
if (!asset || this.tool === 'paint') {
this.cursor = 'default';
return;
}
@@ -641,18 +642,10 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
}
const at = localToImage(this.view, local.x, local.y);
const tolerance = POINT_SIZE / this.view.zoom;
if (this.tool === 'paint') {
const paint = this.shownPaint();
const onHandle =
paint !== null &&
[paint.from, paint.to].some((end) => Math.hypot(end.x - at.x, end.y - at.y) <= tolerance);
this.cursor = onHandle ? 'pointer' : 'default';
return;
}
const selected = this.shape(this.selectedShapeId());
const onGrip = selected?.rings.some((ring) => ringGripAt(ring, at, tolerance) !== null) ?? false;
const inside = asset.shapes.some((shape) => shape.rings.some((ring) => insideRing(ring, at)));
this.cursor = onGrip ? 'pointer' : inside ? 'move' : 'default';
this.cursor = inside && !onGrip ? 'move' : Theme.hoverCursor;
}
/** The ring the pen is adding to, with its shape; null when no line is open or it has closed. */