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.
239 lines
8.9 KiB
JavaScript
239 lines
8.9 KiB
JavaScript
import { colourDistance } from '../lib/harness.mjs';
|
||
import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
|
||
|
||
/**
|
||
* Vector art (#129), stages 2 and 3: the drawing is rasterised and travels the picture path of #185. What
|
||
* only a running editor can show – the shapes are painted by the GPU, so what a check reads here is
|
||
* the picture itself: the fill of a shape, the hole a ring inside it cuts, which of two shapes lies
|
||
* on top, the outline along a ring, and that a changed drawing becomes a new picture while the old
|
||
* one is let go.
|
||
*
|
||
* The shapes of the first blocks are put into the model rather than drawn with the mouse: those
|
||
* checks are about the rasteriser, not about the tool. From 47c on it is the tool that is measured –
|
||
* the pen puts points down, the arrow drags one and takes one away, and what comes out is read from
|
||
* the model, which is the only place a point's coordinates exist exactly.
|
||
*/
|
||
|
||
const CANVAS = 256;
|
||
const FIELD = '#2e8b57';
|
||
const BADGE = '#dd4433';
|
||
const OUTLINE = '#1133ee';
|
||
const REDRAWN = '#e8c020';
|
||
|
||
/** The square field with a square hole in it, and a badge with an outline drawn over it. */
|
||
const SHAPES = [
|
||
{
|
||
id: 'va-field',
|
||
name: 'Field',
|
||
fill: 0x2e8b57,
|
||
opacity: 1,
|
||
rings: [
|
||
{
|
||
closed: true,
|
||
points: [
|
||
{ x: 16, y: 16 },
|
||
{ x: 240, y: 16 },
|
||
{ x: 240, y: 240 },
|
||
{ x: 16, y: 240 },
|
||
],
|
||
},
|
||
{
|
||
closed: true,
|
||
points: [
|
||
{ x: 96, y: 96 },
|
||
{ x: 160, y: 96 },
|
||
{ x: 160, y: 160 },
|
||
{ x: 96, y: 160 },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: 'va-badge',
|
||
name: 'Badge',
|
||
fill: 0xdd4433,
|
||
opacity: 1,
|
||
stroke: { color: 0x1133ee, width: 10 },
|
||
rings: [
|
||
{
|
||
closed: true,
|
||
points: [
|
||
{ x: 176, y: 32 },
|
||
{ x: 232, y: 32 },
|
||
{ x: 232, y: 88 },
|
||
{ x: 176, y: 88 },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
];
|
||
|
||
export async function vectorArt(t) {
|
||
const { check, click, doubleClick, drag, clickText, json, evaluate, mutate, settle, sleep, shot, showTab } =
|
||
t;
|
||
const Y = LANES.vectorArt;
|
||
|
||
// 47a. A drawing on a sprite: what the rasteriser put on the GPU, read off the screen.
|
||
await mutate(`
|
||
const project = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
${MAKE_OBJECT}
|
||
project.assets.push({ id: 'va-art', type: 'vectorart', name: 'Emblem',
|
||
props: { canvasWidth: ${CANVAS}, canvasHeight: ${CANVAS} }, shapes: ${JSON.stringify(SHAPES)} });
|
||
const sprite = structuredClone(project.assets.find((a) => a.id === 'crate'));
|
||
sprite.id = 'va-sprite';
|
||
sprite.name = 'Emblem sprite';
|
||
sprite.props = { ...sprite.props, width: ${CANVAS}, height: ${CANVAS}, color: 0xffffff,
|
||
texture: 'va-art', shader: '' };
|
||
project.assets.push(sprite);
|
||
scene.objects.push(make('va-object', 'va-sprite', '', ${Y}, 'Emblem object'));
|
||
__gm.state.select(null)`);
|
||
await showTab('scene');
|
||
await evaluate(`__gm.lookAt(0, ${Y})`);
|
||
await settle();
|
||
await sleep(300);
|
||
|
||
const picture = await json("__gm.drawnPicture('va-art')");
|
||
check(
|
||
'the drawing lies on the GPU at twice its canvas, and measures the canvas',
|
||
Boolean(picture) &&
|
||
picture.width === CANVAS &&
|
||
picture.height === CANVAS &&
|
||
picture.pixelWidth === CANVAS * 2 &&
|
||
picture.pixelHeight === CANVAS * 2 &&
|
||
picture.kept,
|
||
JSON.stringify(picture),
|
||
);
|
||
|
||
const box = await json("__gm.objectRect('va-object')");
|
||
check('the sprite wearing the drawing is on screen', Boolean(box) && box.w > 32, JSON.stringify(box));
|
||
if (!box) return;
|
||
/**
|
||
* A point of the canvas, in canvas pixels, as the colour on screen. `objectRect` answers with the
|
||
* middle of the object, not its corner, so the canvas is measured out from there.
|
||
*/
|
||
const at = (cx, cy) =>
|
||
json(
|
||
`__gm.pixelAt(${Math.round(box.x + (cx / CANVAS - 0.5) * box.w)}, ${Math.round(box.y + (cy / CANVAS - 0.5) * box.h)})`,
|
||
);
|
||
|
||
const field = await at(48, 200);
|
||
check('the sprite shows the fill of the first shape', colourDistance(field, FIELD) < 14, field);
|
||
|
||
const outside = await json(`__gm.pixelAt(${Math.round(box.x - box.w)}, ${Math.round(box.y)})`);
|
||
// Off the middle of the hole: the scene's grid lines cross exactly there, and a grid line is not
|
||
// the background either – the check would then read the wrong colour for the right reason.
|
||
const hole = await at(112, 148);
|
||
check(
|
||
'the ring inside the shape is a hole – the scene shows through it',
|
||
colourDistance(hole, outside) < 14 && colourDistance(hole, FIELD) > 40,
|
||
JSON.stringify({ hole, outside }),
|
||
);
|
||
|
||
const badge = await at(204, 60);
|
||
check('the shape drawn later lies over the one before it', colourDistance(badge, BADGE) < 14, badge);
|
||
|
||
const outline = await at(176, 60);
|
||
check('the outline is drawn along the ring', colourDistance(outline, OUTLINE) < 24, outline);
|
||
|
||
await shot('47-vector-art');
|
||
|
||
// 47b. A changed drawing is a new picture (#185): the key moves on, the old one is let go.
|
||
const before = picture?.file;
|
||
await mutate(`
|
||
const art = __gm.state.project.assets.find((a) => a.id === 'va-art');
|
||
art.shapes[0].fill = 0x${REDRAWN.slice(1)}`);
|
||
await settle();
|
||
await sleep(300);
|
||
const after = await json("__gm.drawnPicture('va-art')");
|
||
const oldOne = before ? await json(`__gm.textureFilter(${JSON.stringify(before)})`) : null;
|
||
check(
|
||
'a changed drawing gets a key of its own and the old picture is freed',
|
||
Boolean(after) && after.file !== before && after.kept && oldOne === null,
|
||
JSON.stringify({ before, after: after?.file, oldOne }),
|
||
);
|
||
const repainted = await at(48, 200);
|
||
check('the sprite shows the changed fill', colourDistance(repainted, REDRAWN) < 14, repainted);
|
||
|
||
// 47c. The tab: the pen draws a triangle and closes it.
|
||
await evaluate("__gm.openDocument('asset:va-art')");
|
||
await settle();
|
||
const info = await json('__gm.vectorArt.info()');
|
||
check(
|
||
'the Vector art tab shows the drawing and its canvas',
|
||
(await json('__gm.vectorArt.current()')) === 'va-art' && info.startsWith(`${CANVAS} \u00d7 ${CANVAS} px`),
|
||
JSON.stringify({ current: await json('__gm.vectorArt.current()'), info }),
|
||
);
|
||
|
||
await clickText('Pen');
|
||
check('the pen is the tool a press uses', (await json('__gm.vectorArt.tool()')) === 'pen');
|
||
|
||
const TRIANGLE = [
|
||
{ x: 40, y: 40 },
|
||
{ x: 110, y: 40 },
|
||
{ x: 110, y: 110 },
|
||
];
|
||
const screen = [];
|
||
for (const point of TRIANGLE) {
|
||
const at = await json(`__gm.vectorArt.pointAt(${point.x}, ${point.y})`);
|
||
screen.push(at);
|
||
await click(at.x, at.y);
|
||
}
|
||
// The first point again: that is what closes a line, and the pen has nothing open afterwards.
|
||
await click(screen[0].x, screen[0].y);
|
||
await settle();
|
||
|
||
const drawn = (await json("__gm.vectorArt.shapes('va-art')")).at(-1);
|
||
const near = (a, b) => Math.abs(a - b) <= 1.5;
|
||
check(
|
||
'the pen drew a closed shape of three points where it was clicked',
|
||
Boolean(drawn) &&
|
||
drawn.rings.length === 1 &&
|
||
drawn.rings[0].closed &&
|
||
drawn.rings[0].points.length === 3 &&
|
||
drawn.rings[0].points.every((p, i) => near(p.x, TRIANGLE[i].x) && near(p.y, TRIANGLE[i].y)) &&
|
||
(await json('__gm.vectorArt.drawing()')) === false,
|
||
JSON.stringify(drawn?.rings?.[0]),
|
||
);
|
||
|
||
// 47d. The arrow: a point is dragged, one is made round, one is taken away.
|
||
await clickText('Select');
|
||
const corner = await json(`__gm.vectorArt.pointAt(${TRIANGLE[1].x}, ${TRIANGLE[1].y})`);
|
||
const target = await json(`__gm.vectorArt.pointAt(180, 40)`);
|
||
await drag(corner.x, corner.y, target.x, target.y);
|
||
const moved = (await json("__gm.vectorArt.shapes('va-art')")).at(-1).rings[0].points[1];
|
||
check(
|
||
'dragging a point writes it where the pointer left it',
|
||
near(moved.x, 180) && near(moved.y, 40),
|
||
JSON.stringify(moved),
|
||
);
|
||
|
||
const pulled = await json(`__gm.vectorArt.pointAt(180, 40)`);
|
||
await click(pulled.x, pulled.y, 'left', { alt: true });
|
||
const smooth = (await json("__gm.vectorArt.shapes('va-art')")).at(-1).rings[0].points[1];
|
||
check(
|
||
'Alt on a point makes it round – it grows the two handles of a curve',
|
||
Boolean(smooth.in) && Boolean(smooth.out),
|
||
JSON.stringify(smooth),
|
||
);
|
||
|
||
await shot('47-vector-tab');
|
||
|
||
const firstPoint = await json(`__gm.vectorArt.pointAt(${TRIANGLE[0].x}, ${TRIANGLE[0].y})`);
|
||
await doubleClick(firstPoint.x, firstPoint.y);
|
||
const left = (await json("__gm.vectorArt.shapes('va-art')")).at(-1).rings[0].points;
|
||
check(
|
||
'a double-click takes a point away',
|
||
left.length === 2 && near(left[0].x, 180),
|
||
JSON.stringify(left.map((p) => [Math.round(p.x), Math.round(p.y)])),
|
||
);
|
||
|
||
// The drawing is a picture like any other: what the tool changed reaches the sprite by itself.
|
||
const undrawn = await json("__gm.drawnPicture('va-art')");
|
||
check(
|
||
'what was drawn in the tab is the picture the sprite wears',
|
||
Boolean(undrawn) && undrawn.file !== after?.file && undrawn.kept,
|
||
JSON.stringify({ was: after?.file, now: undrawn?.file }),
|
||
);
|
||
}
|