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

+1 -1
View File
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1472 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1477 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+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. */
+57 -1
View File
@@ -1,20 +1,29 @@
/**
* The Curve tab's rules (#247): the curve is laid onto a picture for the vector art gestures and read
* back, a moved point stays between its neighbours, the ends move only up and down, and a press on
* free space puts a point where it falls in `t`.
* free space puts a point where it falls in `t`. The header's fields, the magnet and the dragged
* preview follow the same rules.
*/
import { describe, expect, it } from 'vitest';
import type { VectorPoint } from '../../src/core/model/Asset';
import {
CURVE_SCALE,
CURVE_SNAP,
curveFrame,
curveToImage,
fromImage,
imageToCurve,
isCurveEnd,
previewSecondsAt,
previewShare,
shareAtValue,
snappedToGrid,
toImage,
withCurvePointAt,
withCurvePointMoved,
withCurvePointSet,
} from '../../src/editor/curve/curveView';
import { curveValue } from '../../src/core/model/curve';
const CURVE: VectorPoint[] = [
{ x: 0, y: 0, out: { x: 0.1, y: 0.2 } },
@@ -77,6 +86,31 @@ describe('moving and putting down points', () => {
it('knows the ends', () => {
expect([0, 1, 2].map((i) => isCurveEnd(ring, i))).toEqual([true, false, true]);
});
it('puts a point where its fields say, under the same rule as a drag', () => {
expect(withCurvePointSet(CURVE, 1, { x: 0.3, y: -0.2 })[1]).toMatchObject({
x: 0.3,
y: -0.2,
mode: 'smooth',
});
expect(withCurvePointSet(CURVE, 1, { x: 1.4, y: 0.5 })[1]!.x).toBe(1);
expect(withCurvePointSet(CURVE, 1, { x: -3, y: 0.5 })[1]!.x).toBe(0);
expect(withCurvePointSet(CURVE, 0, { x: 0.4, y: 0.25 })[0]).toMatchObject({ x: 0, y: 0.25 });
expect(withCurvePointSet(CURVE, 1, { x: 0.3, y: 0 })[1]!.out).toEqual(CURVE[1]!.out);
expect(CURVE[1]).toMatchObject({ x: 0.5, y: 1.25 });
});
});
describe('snapping', () => {
it('lands on a grid in t and v, not in picture pixels', () => {
const snapped = fromImage(snappedToGrid(toImage({ x: 0.337, y: 0.912 })));
expect(snapped.x).toBeCloseTo(0.35, 9);
expect(snapped.y).toBeCloseTo(0.9, 9);
const over = fromImage(snappedToGrid(toImage({ x: 0.51, y: -0.18 })));
expect(over.x).toBeCloseTo(0.5, 9);
expect(over.y).toBeCloseTo(-0.2, 9);
expect(CURVE_SNAP).toBe(0.05);
});
});
describe('the preview', () => {
@@ -86,4 +120,26 @@ describe('the preview', () => {
expect(previewShare(1.2)).toBe(1);
expect(previewShare(1.75)).toBeCloseTo(0.25, 12);
});
it('stands at the t it is dragged to', () => {
for (const t of [0, 0.4, 1]) expect(previewShare(previewSecondsAt(t))).toBeCloseTo(t, 12);
expect(previewShare(previewSecondsAt(1.3))).toBe(1);
expect(previewShare(previewSecondsAt(-0.2))).toBe(0);
});
it('jumps to the first t at which the curve reaches a value', () => {
// CURVE rises past 1 before it settles back to 1 – the first crossing is on the way up.
const t = shareAtValue(CURVE, 1);
expect(curveValue(CURVE, t)).toBeCloseTo(1, 3);
expect(t).toBeLessThan(0.5);
const straight = [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
];
expect(shareAtValue(straight, 0.3)).toBeCloseTo(0.3, 6);
expect(shareAtValue(straight, 0)).toBe(0);
// Never reached: where it comes closest.
expect(shareAtValue(straight, 2)).toBe(1);
expect(shareAtValue(straight, -1)).toBe(0);
});
});
+13
View File
@@ -228,6 +228,19 @@ export async function vectorArt(t) {
near(moved.x, 180) && near(moved.y, 40),
JSON.stringify(moved),
);
const cursorOver = async (x, y) => {
const at = await json(`__gm.vectorArt.pointAt(${x}, ${y})`);
await t.mouse('mouseMoved', at.x, at.y);
await settle();
return json('__gm.vectorArt.cursor()');
};
const overPoint = await cursorOver(180, 40);
const overShape = await cursorOver(110, 60);
check(
'over a point the cursor stays the plain arrow, inside the shape it shows the move cross',
overPoint === 'default' && overShape === 'move',
JSON.stringify({ overPoint, overShape }),
);
// The magnet is the graph's (#194): the tab has no Grid button of its own, and with it
// on a ragged drop lands on the 8 px grid. Undone, so the point is back where the next steps expect it.
+157 -9
View File
@@ -136,26 +136,36 @@ export async function curves(t) {
);
await shot('53-curve-tab');
// The preview runs by itself until its button holds it; held, it stands still.
// The preview stands still until its button runs it; pressed again, it holds.
// Three samples over 0.6 s: longer than the half second the dot rests at the end of each pass.
const running = [];
for (let i = 0; i < 3; i++) {
if (i > 0) await sleep(300);
running.push((await json('__gm.curve.preview()'))?.t);
}
const sample = async () => {
const seen = [];
for (let i = 0; i < 3; i++) {
if (i > 0) await sleep(300);
seen.push((await json('__gm.curve.preview()'))?.t);
}
return seen;
};
const previewButton = await json('__gm.curve.previewButton()');
const idle = await sample();
await click(previewButton.x, previewButton.y);
await settle();
const runningButton = await json('__gm.curve.previewButton()');
const running = await sample();
await click(previewButton.x, previewButton.y);
await settle();
const held = [(await json('__gm.curve.preview()'))?.t];
await sleep(250);
held.push((await json('__gm.curve.preview()'))?.t);
check(
'the preview runs by itself, and the Preview button holds it',
previewButton.toggled &&
'the preview stands still at first, the Preview button runs it, and pressed again holds it',
!previewButton.toggled &&
new Set(idle).size === 1 &&
runningButton.toggled &&
new Set(running).size > 1 &&
!(await json('__gm.curve.previewButton()')).toggled &&
held[0] === held[1],
JSON.stringify({ previewButton, running, held }),
JSON.stringify({ previewButton, idle, running, held }),
);
// The preview's dot sits on the line at its t, and its twin beside the graph at the same height.
@@ -318,6 +328,8 @@ export async function curves(t) {
);
await shot('53-curve-preset');
await headerAndScrub(t, curveId, bounced);
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
@@ -326,3 +338,139 @@ export async function curves(t) {
__gm.state.select(null)`);
await showTab('scene');
}
/**
* 53h–53k on the Bounce out curve the preset left: the picked point's `t` and `v` fields, the plain
* arrow over points, the magnet, and the preview dragged by its knob and by its dot beside the graph.
*/
async function headerAndScrub(t, curveId, bounced) {
const { check, json, evaluate, click, drag, settle, shot, undo } = t;
const pointsOf = () => json(`__gm.curve.points(${JSON.stringify(curveId)})`);
const at = (tt, v) => json(`__gm.curve.at(${tt}, ${v})`);
const onGrid = (n) => Math.abs(n / 0.05 - Math.round(n / 0.05)) < 1e-6;
await evaluate(`__gm.openDocument('asset:${curveId}')`);
await settle();
const fit = await json('__gm.curve.fitAt()');
await click(fit.x, fit.y);
await settle();
// Somewhere left of t 0: no point goes there, so a click only drops the pick.
const aside = await at(-0.08, 0.5);
// 53h. A picked point shows its t and v in the header; typed in, it goes there, one undo step each.
const target = bounced[2];
const spot = await at(target.x, target.y);
await click(spot.x, spot.y);
await settle();
const shownT = await json("__gm.curve.placeField('t')");
const shownV = await json("__gm.curve.placeField('v')");
const typeInto = async (axis, text) => {
const field = await json(`__gm.curve.placeField(${JSON.stringify(axis)})`);
await click(field.x, field.y);
await t.typeText(text);
await t.press('Enter');
await settle();
};
const middle = ((bounced[1].x + bounced[3].x) / 2).toFixed(3);
await typeInto('t', middle);
await typeInto('v', '0.42');
const typed = await pointsOf();
await click(aside.x, aside.y);
await undo();
const oneBack = await pointsOf();
await undo();
const twoBack = await pointsOf();
check(
'the t and v fields show the picked point, move it when typed into, and each is one undo step',
(await json('__gm.curve.picked()')) === -1 &&
shownT.enabled &&
Math.abs(Number(shownT.text) - target.x) < 0.001 &&
Math.abs(Number(shownV.text) - target.y) < 0.001 &&
typed?.length === bounced.length &&
typed[2].x === Number(middle) &&
typed[2].y === 0.42 &&
oneBack[2].x === Number(middle) &&
oneBack[2].y === target.y &&
JSON.stringify(twoBack) === JSON.stringify(bounced),
JSON.stringify({
shownT,
shownV,
middle,
typed: typed?.[2],
oneBack: oneBack?.[2],
twoBack: twoBack?.[2],
}),
);
// 53i. An end keeps its t: the field is off, its v is not. Over a point the arrow stays plain.
const start = await at(0, bounced[0].y);
await click(start.x, start.y);
await settle();
const endT = await json("__gm.curve.placeField('t')");
const endV = await json("__gm.curve.placeField('v')");
await t.mouse('mouseMoved', start.x, start.y);
await settle();
const overPoint = await json('__gm.curve.cursor()');
const free = await at(0.5, 0.2);
await t.mouse('mouseMoved', free.x, free.y);
await settle();
const overSpace = await json('__gm.curve.cursor()');
check(
"an end's t field is off and its v field on; over a point the cursor stays the plain arrow",
!endT.enabled && endV.enabled && overPoint === 'default' && overSpace === 'crosshair',
JSON.stringify({ endT, endV, overPoint, overSpace }),
);
// 53j. With the magnet on, a point put down and then dragged lands on the 0.05 grid in t and v.
const magnet = await json('__gm.curve.snapButton()');
await click(magnet.x, magnet.y);
await settle();
const put = await at(0.337, 0.412);
await click(put.x, put.y);
await settle();
const index = await json('__gm.curve.picked()');
const dropped = (await pointsOf())?.[index];
await drag(put.x, put.y, put.x + 3, put.y - 37, { steps: 8 });
const dragged = (await pointsOf())?.[index];
const magnetOn = await json('__gm.curve.snapButton()');
await click(magnet.x, magnet.y);
await click(aside.x, aside.y);
await undo();
await undo();
const restored = await pointsOf();
check(
'with the magnet on a point is put and dragged onto the 0.05 grid of t and v',
!magnet.toggled &&
magnetOn.toggled &&
!(await json('__gm.curve.snapButton()')).toggled &&
Math.abs(dropped?.x - 0.35) < 1e-6 &&
Math.abs(dropped?.y - 0.4) < 1e-6 &&
onGrid(dragged?.x) &&
onGrid(dragged?.y) &&
dragged.y > dropped.y + 0.04 &&
JSON.stringify(restored) === JSON.stringify(bounced),
JSON.stringify({ magnet, dropped, dragged, restored: restored?.length }),
);
// 53k. Held, the preview moves only when dragged: its knob sideways sets t, its dot beside the
// graph up or down jumps to the first t the curve reaches that height. The curve stays.
const before = await json('__gm.curve.preview()');
const seventy = await at(0.7, 0);
await drag(before.knob.x, before.knob.y, seventy.x, before.knob.y, { steps: 10 });
const scrubbed = await json('__gm.curve.preview()');
const half = await at(0, 0.5);
await drag(scrubbed.bar.x, scrubbed.bar.y, scrubbed.bar.x, half.y, { steps: 10 });
const jumped = await json('__gm.curve.preview()');
await shot('53-curve-scrub');
const earlier = expectedAt(bounced, Math.max(jumped.t - 0.02, 0));
check(
'dragging the knob puts the preview at that t, dragging its twin jumps to where the curve first reaches that value',
!(await json('__gm.curve.previewButton()')).toggled &&
Math.abs(scrubbed.t - 0.7) < 0.01 &&
Math.abs(scrubbed.v - expectedAt(bounced, scrubbed.t)) < 0.002 &&
Math.abs(jumped.v - 0.5) < 0.01 &&
earlier < 0.5 &&
jumped.t < scrubbed.t &&
JSON.stringify(await pointsOf()) === JSON.stringify(bounced),
JSON.stringify({ before, scrubbed, jumped, earlier }),
);
}