From a5ae21481b2e9fb7f91d30feb0176c5bd89eae56 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sun, 27 Sep 2026 16:45:53 +0200 Subject: [PATCH] Type point values, snap and scrub the preview in the curve tab (#248) 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 --- README.md | 2 +- src/editor/curve/CurveCanvas.ts | 133 +++++++++++++++++-- src/editor/curve/CurveEditor.ts | 48 ++++++- src/editor/curve/curveView.ts | 58 ++++++++- src/editor/debug/curveHooks.ts | 10 ++ src/editor/debug/vectorArtHooks.ts | 1 + src/editor/pixelart/PaletteGrid.ts | 2 +- src/editor/vectorart/VectorArtCanvas.ts | 13 +- tests/editor/curve-view.test.ts | 58 ++++++++- tools/e2e/47-vectorart.mjs | 13 ++ tools/e2e/53-curves.mjs | 166 ++++++++++++++++++++++-- 11 files changed, 461 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index eed0d62a..1ed91f8e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/editor/curve/CurveCanvas.ts b/src/editor/curve/CurveCanvas.ts index 5935a3a1..d19625fb 100644 --- a/src/editor/curve/CurveCanvas.ts +++ b/src/editor/curve/CurveCanvas.ts @@ -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 { /** 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 { 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 { 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 { 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 { 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 { 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 { 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 { } 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 { } 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 { } } - /** 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 { 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) diff --git a/src/editor/curve/CurveEditor.ts b/src/editor/curve/CurveEditor.ts index a934c4a6..b43aeb01 100644 --- a/src/editor/curve/CurveEditor.ts +++ b/src/editor/curve/CurveEditor.ts @@ -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 = { 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 { /** 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 { 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 { this.fitButton.setEnabled(asset !== null); this.presetButton.setEnabled(asset !== null); this.previewButton.setEnabled(asset !== null); + this.snap.setEnabled(asset !== null); this.syncPoint(); } } diff --git a/src/editor/curve/curveView.ts b/src/editor/curve/curveView.ts index cc6d7a9c..8a499ecd 100644 --- a/src/editor/curve/curveView.ts +++ b/src/editor/curve/curveView.ts @@ -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; +} diff --git a/src/editor/debug/curveHooks.ts b/src/editor/debug/curveHooks.ts index f22ef54b..34f78494 100644 --- a/src/editor/debug/curveHooks.ts +++ b/src/editor/debug/curveHooks.ts @@ -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), diff --git a/src/editor/debug/vectorArtHooks.ts b/src/editor/debug/vectorArtHooks.ts index e273a0b8..b060c5ee 100644 --- a/src/editor/debug/vectorArtHooks.ts +++ b/src/editor/debug/vectorArtHooks.ts @@ -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, diff --git a/src/editor/pixelart/PaletteGrid.ts b/src/editor/pixelart/PaletteGrid.ts index 9a7fcd13..ca423b5d 100644 --- a/src/editor/pixelart/PaletteGrid.ts +++ b/src/editor/pixelart/PaletteGrid.ts @@ -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(); diff --git a/src/editor/vectorart/VectorArtCanvas.ts b/src/editor/vectorart/VectorArtCanvas.ts index 5d98bbd0..6dea8e36 100644 --- a/src/editor/vectorart/VectorArtCanvas.ts +++ b/src/editor/vectorart/VectorArtCanvas.ts @@ -629,9 +629,10 @@ export class VectorArtCanvas extends SheetCanvas { 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 { } 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. */ diff --git a/tests/editor/curve-view.test.ts b/tests/editor/curve-view.test.ts index 5a358fd0..b7f0930b 100644 --- a/tests/editor/curve-view.test.ts +++ b/tests/editor/curve-view.test.ts @@ -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); + }); }); diff --git a/tools/e2e/47-vectorart.mjs b/tools/e2e/47-vectorart.mjs index 0680d810..b7900df2 100644 --- a/tools/e2e/47-vectorart.mjs +++ b/tools/e2e/47-vectorart.mjs @@ -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. diff --git a/tools/e2e/53-curves.mjs b/tools/e2e/53-curves.mjs index 4b5767c4..7596be3f 100644 --- a/tools/e2e/53-curves.mjs +++ b/tools/e2e/53-curves.mjs @@ -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 }), + ); +}