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>
146 lines
5.7 KiB
TypeScript
146 lines
5.7 KiB
TypeScript
/**
|
||
* 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`. 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 } },
|
||
{ x: 0.5, y: 1.25, in: { x: -0.2, y: 0 }, out: { x: 0.2, y: 0 }, mode: 'smooth' },
|
||
{ x: 1, y: 1 },
|
||
];
|
||
|
||
describe('the curve on the picture', () => {
|
||
it('lays t to the right and v upwards, and reads it back unchanged', () => {
|
||
const ring = curveToImage(CURVE);
|
||
expect(ring.closed).toBe(false);
|
||
expect(ring.points[0]).toMatchObject({
|
||
x: 0,
|
||
y: CURVE_SCALE,
|
||
out: { x: 0.1 * CURVE_SCALE, y: -0.2 * CURVE_SCALE },
|
||
});
|
||
expect(ring.points[1]).toMatchObject({ x: CURVE_SCALE / 2, y: -0.25 * CURVE_SCALE, mode: 'smooth' });
|
||
expect(imageToCurve(ring)).toEqual(CURVE);
|
||
});
|
||
|
||
it('frames 0…1 in t and everything the curve reaches in v', () => {
|
||
const frame = curveFrame(CURVE);
|
||
expect(frame.x).toBeLessThan(0);
|
||
expect(frame.x + frame.w).toBeGreaterThan(CURVE_SCALE);
|
||
// The top of the frame lies above v = 1.25, the bottom below v = 0.
|
||
expect(frame.y).toBeLessThan(-0.25 * CURVE_SCALE);
|
||
expect(frame.y + frame.h).toBeGreaterThan(CURVE_SCALE);
|
||
});
|
||
});
|
||
|
||
describe('moving and putting down points', () => {
|
||
const ring = curveToImage(CURVE);
|
||
|
||
it('keeps a moved point between its neighbours in t', () => {
|
||
const right = withCurvePointMoved(ring, 1, { x: 900, y: 10 });
|
||
expect(right.points[1]).toMatchObject({ x: CURVE_SCALE, y: 10 });
|
||
const left = withCurvePointMoved(ring, 1, { x: -50, y: 10 });
|
||
expect(left.points[1]!.x).toBe(0);
|
||
expect(withCurvePointMoved(ring, 1, { x: 64, y: 20 }).points[1]).toMatchObject({ x: 64, y: 20 });
|
||
// Handles ride along: they are offsets.
|
||
expect(right.points[1]!.out).toEqual(ring.points[1]!.out);
|
||
});
|
||
|
||
it('moves the two ends only up and down', () => {
|
||
expect(withCurvePointMoved(ring, 0, { x: 40, y: 30 }).points[0]).toMatchObject({ x: 0, y: 30 });
|
||
expect(withCurvePointMoved(ring, 2, { x: 10, y: 30 }).points[2]).toMatchObject({ x: CURVE_SCALE, y: 30 });
|
||
});
|
||
|
||
it('puts a point on free space where it falls in t, and none outside the curve', () => {
|
||
const put = withCurvePointAt(ring, { x: 200, y: 40 })!;
|
||
expect(put.index).toBe(2);
|
||
expect(put.ring.points.map((p) => p.x)).toEqual([0, CURVE_SCALE / 2, 200, CURVE_SCALE]);
|
||
expect(put.ring.points[2]).toEqual({ x: 200, y: 40 });
|
||
expect(withCurvePointAt(ring, { x: 20, y: 0 })!.index).toBe(1);
|
||
expect(withCurvePointAt(ring, { x: -1, y: 0 })).toBeNull();
|
||
expect(withCurvePointAt(ring, { x: CURVE_SCALE + 1, y: 0 })).toBeNull();
|
||
expect(withCurvePointAt(ring, { x: 0, y: 0 })).toBeNull();
|
||
});
|
||
|
||
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', () => {
|
||
it('climbs through t for a second, rests at the end, then starts over', () => {
|
||
expect(previewShare(0)).toBe(0);
|
||
expect(previewShare(0.25)).toBeCloseTo(0.25, 12);
|
||
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);
|
||
});
|
||
});
|