/** * 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); }); });