Files
feuerwerk/tools/e2e/47-vectorart.mjs
T
adminandClaude Opus 5 78595ec80f
game-maker / gate (push) Successful in 2m12s
game-maker / suite (push) Successful in 7m45s
A selected vector shape has its own inspector rows (#129)
Stage 4 of #129. Until now the inspector showed the rows of the asset
when a shape was selected, so there was no way to give a shape a colour,
an outline or a place in the drawing order without editing the project
file. The rows for a shape now sit where every other selection puts its
rows.

- SHAPES lists every shape of the asset, topmost row drawn last, the
  same way a layer list reads. Selecting the asset itself shows the same
  list below its own rows, with no shape selected.
- Each row paints its shape with the commands the rasterizer uses
  (shapeDrawing plus paintDrawing) into a 22 px swatch, the whole canvas
  scaled in, so the row also says where on the canvas the shape sits.
- The four order buttons from the issue (forward, to front, backward, to
  back) sit in two rows of two, and a row can be dragged to another
  place with a marker line. They are disabled when there is nothing to
  move.
- VECTOR SHAPE carries name, fill, opacity and an outline checkbox, the
  checkbox adding and removing the stroke instead of hiding it; line
  colour and width appear with it. A dragged colour slider is one undo
  step, the style rows going through previewingHandlers like every other
  row.
- A line width of 0 is no stroke, which the command rejects, so the row
  clamps at 0.1.

Findings:

- locate('Outline') aims beside the control, because it gives the centre
  of the label text and a wider label pushes a fixed offset out of the
  field. __gm.inspectorField(label) knows the centre of the control.
- A click into a text field sets the cursor and selects nothing, so a
  typed hex landed appended to the old one and parseHex returned null
  without anything failing - typeText only checks that the text is
  contained. A Ctrl+A goes first now.
- Four buttons side by side cut "Backward" to "Backw...", which only the
  screenshot showed. Two rows of two now, as in the tilemap panel.

Tests: ./check.sh 128 files, 1897 tests (was 127/1883). Full e2e suite
locally instead of only CI because the change sits in the inspector and
touches every selection: ALL PASSED - 1179 checks (was 1172), module 47
at 24 checks (was 17). Three counter-checks hold: not reversing the list
fails 1 unit test and 2 e2e checks, dropping the gap correction when
dropping a row fails 2 unit tests and the drag check, and a constant
signature kills module 47 because the outline rows never appear.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 22:29:40 +02:00

345 lines
13 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,
locate,
mutate,
press,
settle,
sleep,
shot,
showTab,
typeText,
} = 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 }),
);
// 47e. The inspector (stage 4): the shapes are listed, picked, coloured and reordered there.
const shapeIds = async () => (await json("__gm.vectorArt.shapes('va-art')")).map((s) => s.id);
const shapeOf = async (id) =>
(await json("__gm.vectorArt.shapes('va-art')")).find((shape) => shape.id === id);
const rows = await json('__gm.vectorArt.shapeRows()');
const model = await shapeIds();
check(
'the inspector lists the shapes, the one drawn last on top',
Array.isArray(rows) && rows.length === model.length && rows.join() === [...model].reverse().join(),
JSON.stringify({ rows, model }),
);
// A row is picked by where it sits: its name also stands in the tab, so text would be ambiguous.
const fieldRow = await json("__gm.vectorArt.shapeRowAt('va-field')");
await click(fieldRow.x + fieldRow.w / 2, fieldRow.y + fieldRow.h / 2);
await settle();
check(
'clicking a row selects that shape',
(await json('__gm.vectorArt.selected()')) === 'va-field',
JSON.stringify({ selected: await json('__gm.vectorArt.selected()'), row: fieldRow }),
);
// The control of a row, not an offset from its label: a wider label pushes its control right.
const fillField = await json("__gm.inspectorField('Fill')");
await click(fillField.x, fillField.y);
// The click puts a caret in the hex text rather than selecting it, so the old value goes first.
await press('a', { ctrl: true });
await typeText('20c0e8');
await press('Enter');
await settle();
const recoloured = await shapeOf('va-field');
// Read off the tab as well: a row that only writes the model would show nothing to the eye.
const spot = await json('__gm.vectorArt.pointAt(48, 200)');
const painted = await json(`__gm.pixelAt(${Math.round(spot.x)}, ${Math.round(spot.y)})`);
check(
'the Fill row recolours the shape, and the tab shows it',
recoloured?.fill === 0x20c0e8 && colourDistance(painted, '#20c0e8') < 14,
JSON.stringify({ fill: recoloured?.fill?.toString(16), painted }),
);
const outlineField = await json("__gm.inspectorField('Outline')");
await click(outlineField.x, outlineField.y);
await settle();
const outlined = await shapeOf('va-field');
check(
'the Outline switch gives the shape a line, and its rows appear with it',
Boolean(outlined?.stroke) && outlined.stroke.width === 2 && Boolean(await locate('Line width')),
JSON.stringify(outlined?.stroke),
);
// The badge was drawn over the field; "To front" puts the field over the badge instead.
const covered = await json('__gm.vectorArt.pointAt(204, 60)');
const wasBadge = await json(`__gm.pixelAt(${Math.round(covered.x)}, ${Math.round(covered.y)})`);
await clickText('To front');
await settle();
const nowField = await json(`__gm.pixelAt(${Math.round(covered.x)}, ${Math.round(covered.y)})`);
check(
'To front draws the shape over the one that covered it',
(await shapeIds()).at(-1) === 'va-field' &&
colourDistance(wasBadge, BADGE) < 14 &&
colourDistance(nowField, '#20c0e8') < 14,
JSON.stringify({ order: await shapeIds(), wasBadge, nowField }),
);
await shot('47-shape-rows');
// Dragging the top row to the bottom of the list is the same move the other way round.
const top = await json("__gm.vectorArt.shapeRowAt('va-field')");
await drag(
top.x + top.w / 2,
top.y + top.h / 2,
top.x + top.w / 2,
top.y + top.h * (await shapeIds()).length,
);
await settle();
check(
'dragging a row to the bottom draws that shape first',
(await shapeIds())[0] === 'va-field',
JSON.stringify(await shapeIds()),
);
const before47e = (await shapeIds()).length;
await clickText('Add shape');
await settle();
const added = await shapeIds();
check(
'Add shape puts an empty shape on top and selects it',
added.length === before47e + 1 && (await json('__gm.vectorArt.selected()')) === added.at(-1),
JSON.stringify({ added: added.length, selected: await json('__gm.vectorArt.selected()') }),
);
}