Stages 1 and 2 gave the asset a model and a picture, but the only way to put a shape into it was a test calling mutate. The tab closes that gap: what you draw there is what the sprite in the scene carries, with no extra step, because the fingerprint changes and the picture registry of #185 redraws. - The tab has a header with Select, Pen, Grid and Fit, a chequerboard behind the canvas and the canvas itself as a frame. It paints with the same commands as the rasterizer, paintDrawing being exported from viewport/vectorArtPicture.ts for it, so the tab cannot drift away from the picture. - Pen sets points on a click and pulls the handles out on a drag, both of them mirrored; a click on the first point closes the ring and Esc ends an open line. - Select drags a point, a handle or a whole shape, a click on an edge puts a point there, a double click takes one away, and Alt turns a corner round and back. Delete removes the selected shape. - The rules grew inside polygonEdit.ts instead of a second module, as the plan says, so a collider polygon and a frame outline get the same behaviour where it applies. - A point on a curved edge splits the curve by de Casteljau instead of cutting it: both halves together draw exactly what was there before. - A curved edge is walked instead of measured over its chord, or a click on the bow of a curve would grab nothing; the same walk carries the test for the inside of a ring. - One gesture is one undo step. The preview writes the model directly and the old state is put back on release before the command runs, which is the pattern the frame outlines of #141 already use. - state.ts gained a selection kind "shape". The inspector shows the rows of the asset for it, the shape list with colour, opacity and stroke being stage 4. One counter-check was no counter-check: disabling the pen's closing tolerance by multiplying it with 0 left the run green, and rightly so, because the closing click lands on exactly the screen coordinate point 0 came from, so the distance is 0 and <= 0 is true. Only an unsatisfiable condition really switched it off, and then 2 checks fail. A second trap on the way: a false && in front of the guard breaks the TS narrowing, npm run build bails out, and two runs went against the old bundle. Tests: check.sh 127 files, 1878 tests, EXIT 0 (was 126 / 1860). Full e2e suite ALL PASSED - 1172 checks, module 47 alone 17 (was 10). 18 new unit tests in tests/editor/ring-edit.test.ts and 7 new e2e checks in blocks 47c and 47d. Two counter-checks hold: no de Casteljau fails 1 unit test, measuring a curve over its chord fails another. Originals checked back with cmp and rebuilt.
175 lines
6.1 KiB
TypeScript
175 lines
6.1 KiB
TypeScript
/**
|
||
* 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 { VectorRing } from '../../src/core/model/Asset';
|
||
import {
|
||
insideRing,
|
||
nearestOnSegment,
|
||
pointOnSegment,
|
||
ringGripAt,
|
||
ringMoved,
|
||
ringOutline,
|
||
ringSegments,
|
||
withHandleMoved,
|
||
withoutPoint,
|
||
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 } },
|
||
],
|
||
};
|
||
}
|
||
|
||
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', () => {
|
||
const ring: VectorRing = {
|
||
closed: false,
|
||
points: [{ x: 50, y: 50, in: { x: -10, y: 0 }, out: { x: 10, y: 0 } }],
|
||
};
|
||
const bent = withHandleMoved(ring, 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('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('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 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);
|
||
});
|
||
});
|