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 }), + ); +}