Files
feuerwerk/tools/e2e/47-vectorart.mjs
T
admin 3d7ad65f34
game-maker / gate (push) Canceled after 37s
game-maker / suite (push) Canceled after 37s
Vector art has a tab to draw in, which is stage 3 of #129
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.
2026-09-20 21:34:14 +02:00

239 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }),
);
}