/** * The editing rules of a vector art ring (#129, stage 3). They are the gestures of the Vector art * tab without a view: what the pointer grabs, what a drag writes, and what putting a point on a * line does to the line. */ import { describe, expect, it } from 'vitest'; import type { VectorPointMode, VectorRing } from '../../src/core/model/Asset'; import { insideRing, nearestOnSegment, pointKind, pointOnSegment, ringGripAt, ringMoved, ringOutline, ringSegments, withHandleMoved, withoutPoint, withPointKind, withPointMoved, withPointOnSegment, withSmoothToggled, } from '../../src/editor/polygonEdit'; /** A square of 100, corners only. */ function square(): VectorRing { return { closed: true, points: [ { x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }, { x: 0, y: 100 }, ], }; } /** Two points with a handle each, bulging upwards between them. */ function arc(): VectorRing { return { closed: false, points: [ { x: 0, y: 0, out: { x: 0, y: -40 } }, { x: 100, y: 0, in: { x: 0, y: -40 } }, ], }; } /** One point at (50, 50) with a handle 10 to either side, in the given mode. */ function handled(mode: VectorPointMode): VectorRing { return { closed: false, points: [{ x: 50, y: 50, in: { x: -10, y: 0 }, out: { x: 10, y: 0 }, mode }] }; } describe('what the pointer grabs', () => { it('takes the nearest point', () => { expect(ringGripAt(square(), { x: 98, y: 3 }, 8)).toEqual({ kind: 'point', index: 1 }); }); it('offers nothing further away than the tolerance', () => { expect(ringGripAt(square(), { x: 50, y: 50 }, 8)).toBeNull(); }); it('gives a handle before the point it belongs to', () => { const ring = arc(); // The handle of point 0 sits at (0, −40); its own point is 40 away, so both are in reach at 50. expect(ringGripAt(ring, { x: 0, y: -38 }, 50)).toEqual({ kind: 'handle', index: 0, side: 'out' }); }); it('only offers the handles that are drawn', () => { const ring = arc(); expect(ringGripAt(ring, { x: 0, y: -38 }, 50, [1])).toEqual({ kind: 'point', index: 0 }); }); it('gives the segment under the pointer, with how far along it lies', () => { const grip = ringGripAt(square(), { x: 50, y: 2 }, 8); expect(grip?.kind).toBe('segment'); if (grip?.kind !== 'segment') throw new Error('no segment'); expect(grip.index).toBe(0); expect(grip.at).toEqual({ x: 50, y: 0 }); expect(grip.t).toBeCloseTo(0.5, 6); }); it('finds the curve of a curved segment, not its chord', () => { // The chord runs along y = 0; the curve reaches up to about y = −30 in its middle. const hit = nearestOnSegment(arc().points[0]!, arc().points[1]!, { x: 50, y: -30 }); expect(hit.distance).toBeLessThan(1); expect(hit.t).toBeCloseTo(0.5, 1); }); it('walks the way back to the first point only on a closed ring', () => { expect(ringSegments(square()).length).toBe(4); expect(ringSegments({ ...square(), closed: false }).length).toBe(3); }); }); describe('dragging', () => { it('moves a point and takes its handles along', () => { const moved = withPointMoved(arc(), 0, { x: 10, y: 10 }); expect(moved.points[0]).toEqual({ x: 10, y: 10, out: { x: 0, y: -40 } }); }); it('writes a handle as an offset and mirrors the other one on a symmetric point', () => { const bent = withHandleMoved(handled('symmetric'), 0, 'out', { x: 70, y: 60 }); expect(bent.points[0]!.out).toEqual({ x: 20, y: 10 }); expect(bent.points[0]!.in).toEqual({ x: -20, y: -10 }); }); it('leaves the other handle alone on a corner (#195)', () => { const bent = withHandleMoved(handled('corner'), 0, 'out', { x: 50, y: 20 }); expect(bent.points[0]!.out).toEqual({ x: 0, y: -30 }); expect(bent.points[0]!.in).toEqual({ x: -10, y: 0 }); }); it('turns the other handle round at its own length on a smooth point (#195)', () => { // `in` is 10 long; `out` is pulled 30 up, so `in` has to point 10 down. const bent = withHandleMoved(handled('smooth'), 0, 'out', { x: 50, y: 20 }); expect(bent.points[0]!.out).toEqual({ x: 0, y: -30 }); expect(bent.points[0]!.in!.x).toBeCloseTo(0, 9); expect(bent.points[0]!.in!.y).toBeCloseTo(10, 9); }); it('grows the mirrored handle the pen asks for, so a pen curve has two (#195)', () => { const fresh: VectorRing = { closed: false, points: [{ x: 50, y: 50 }] }; const pulled = withHandleMoved(fresh, 0, 'out', { x: 70, y: 50 }, 'symmetric'); const point = pulled.points[0]!; expect(point.mode).toBe('symmetric'); expect([point.out!.x, point.out!.y, point.in!.x, point.in!.y].map((v) => v + 0)).toEqual([20, 0, -20, 0]); }); it('takes a handle away when it is dropped on its own point', () => { const straightened = withHandleMoved(arc(), 0, 'out', { x: 0, y: 0 }); expect(straightened.points[0]!.out).toBeUndefined(); }); it('takes the mirror along on a symmetric point, which leaves it straight', () => { const straightened = withHandleMoved(handled('symmetric'), 0, 'out', { x: 50, y: 50 }); expect(straightened.points[0]).toEqual({ x: 50, y: 50 }); }); it('moves a whole ring', () => { const moved = ringMoved(square(), 10, -5); expect(moved.points.map((p) => [p.x, p.y])).toEqual([ [10, -5], [110, -5], [110, 95], [10, 95], ]); }); }); describe('adding and taking away', () => { it('puts a corner on a straight segment', () => { const next = withPointOnSegment(square(), 0, 0.5); expect(next.points.length).toBe(5); expect(next.points[1]).toEqual({ x: 50, y: 0 }); }); it('splits a curved segment without changing the curve', () => { const before = arc(); const after = withPointOnSegment(before, 0, 0.5); expect(after.points.length).toBe(3); // The two halves together have to draw what the one segment drew: measured at five places. for (const t of [0, 0.25, 0.5, 0.75, 1]) { const was = pointOnSegment(before.points[0]!, before.points[1]!, t); const half = t <= 0.5 ? 0 : 1; const now = pointOnSegment(after.points[half]!, after.points[half + 1]!, (t - half * 0.5) * 2); expect(now.x).toBeCloseTo(was.x, 6); expect(now.y).toBeCloseTo(was.y, 6); } }); it('takes a point away, down to the last one', () => { const three = withoutPoint(square(), 3); expect(three?.points.length).toBe(3); expect(withoutPoint({ closed: false, points: [{ x: 0, y: 0 }] }, 0)).toBeNull(); }); it('makes a corner round and a round point square again', () => { // Corner 1 lies between (0, 0) and (100, 100), so its handles point along that line. const round = withSmoothToggled(square(), 1); expect(round.points[1]!.out).toEqual({ x: 100 / 3, y: 100 / 3 }); expect(round.points[1]!.in).toEqual({ x: -100 / 3, y: -100 / 3 }); expect(withSmoothToggled(round, 1).points[1]).toEqual({ x: 100, y: 0 }); }); }); describe('the four point types (#195)', () => { it('reads a point without handles as straight and one without a mode as a corner', () => { expect(pointKind({ x: 0, y: 0 })).toBe('straight'); expect(pointKind({ x: 0, y: 0, mode: 'smooth' })).toBe('straight'); expect(pointKind(arc().points[0]!)).toBe('corner'); expect(pointKind(handled('symmetric').points[0]!)).toBe('symmetric'); }); it('makes a point straight by taking both handles and the mode away', () => { expect(withPointKind(handled('smooth'), 0, 'straight').points[0]).toEqual({ x: 50, y: 50 }); }); it('grows handles on a straight point that becomes smooth', () => { const smooth = withPointKind(square(), 1, 'smooth').points[1]!; expect(smooth.mode).toBe('smooth'); expect(smooth.out).toEqual({ x: 100 / 3, y: 100 / 3 }); expect(smooth.in).toEqual({ x: -100 / 3, y: -100 / 3 }); }); it('keeps a corner’s handles where they are', () => { const bent: VectorRing = { closed: false, points: [{ x: 0, y: 0, in: { x: -10, y: 0 }, out: { x: 0, y: -20 } }], }; expect(withPointKind(bent, 0, 'corner').points[0]).toEqual(bent.points[0]); }); it('lines the handles up for smooth and keeps each length', () => { // `in` points left (10 long), `out` up (20 long): the bisector runs up and to the right. const bent: VectorRing = { closed: false, points: [{ x: 0, y: 0, in: { x: -10, y: 0 }, out: { x: 0, y: -20 } }], }; const point = withPointKind(bent, 0, 'smooth').points[0]!; const s = Math.SQRT1_2; expect(point.out!.x).toBeCloseTo(20 * s, 9); expect(point.out!.y).toBeCloseTo(-20 * s, 9); expect(point.in!.x).toBeCloseTo(-10 * s, 9); expect(point.in!.y).toBeCloseTo(10 * s, 9); }); it('gives both handles their mean length for symmetric', () => { const bent: VectorRing = { closed: false, points: [{ x: 0, y: 0, in: { x: -10, y: 0 }, out: { x: 30, y: 0 } }], }; const point = withPointKind(bent, 0, 'symmetric').points[0]!; expect(point.mode).toBe('symmetric'); expect([point.in!.x, point.in!.y, point.out!.x, point.out!.y].map((v) => v + 0)).toEqual([-20, 0, 20, 0]); }); it('grows the missing second handle opposite the first', () => { const point = withPointKind(arc(), 0, 'smooth').points[0]!; expect(point.out).toEqual({ x: 0, y: -40 }); expect(point.in!.x).toBeCloseTo(0, 9); expect(point.in!.y).toBeCloseTo(40, 9); }); it('does not reach round an open line for the neighbour of its end', () => { // The first point of an open L has one neighbour; wrapping to the last would tilt its handles. const line: VectorRing = { closed: false, points: [ { x: 0, y: 0 }, { x: 90, y: 0 }, { x: 90, y: 90 }, ], }; expect(withPointKind(line, 0, 'smooth').points[0]!.out).toEqual({ x: 30, y: 0 }); }); it('splits a symmetric curve into smooth points – the halves are no longer the same length', () => { const curve: VectorRing = { closed: false, points: [ { x: 0, y: 0, in: { x: 0, y: 30 }, out: { x: 0, y: -30 }, mode: 'symmetric' }, { x: 100, y: 0, in: { x: 0, y: -30 }, out: { x: 0, y: 30 }, mode: 'symmetric' }, ], }; const split = withPointOnSegment(curve, 0, 0.25); expect(split.points.map((p) => p.mode)).toEqual(['smooth', 'smooth', 'smooth']); }); it('keeps a straight point on a straight segment free of a mode', () => { expect(withPointOnSegment(square(), 0, 0.5).points[1]).toEqual({ x: 50, y: 0 }); }); }); describe('the inside of a ring', () => { it('holds what lies in it and not what lies beside it', () => { expect(insideRing(square(), { x: 50, y: 50 })).toBe(true); expect(insideRing(square(), { x: 150, y: 50 })).toBe(false); }); it('is empty while the ring is open – a line encloses nothing', () => { expect(insideRing({ ...square(), closed: false }, { x: 50, y: 50 })).toBe(false); }); it('follows a curve rather than the chord it spans', () => { // A closed arc bulging up to y = −30: a point above the chord is inside it, one below is not. const bulge: VectorRing = { closed: true, points: arc().points }; expect(insideRing(bulge, { x: 50, y: -10 })).toBe(true); expect(insideRing(bulge, { x: 50, y: 10 })).toBe(false); expect(ringOutline(bulge).length).toBeGreaterThan(2); }); });