Files
feuerwerk/tools/e2e/47-vectorart.mjs
T
adminandClaude Opus 5.5 a848cf8469
game-maker / gate (push) Successful in 3m48s
game-maker / suite (push) Successful in 15m0s
Pick a new asset from category submenus in the + menu
The + menu was one long list in two groups, placeable types and
library ones. That split is no longer wanted. The menu now lists the
categories of the asset panel, and each opens its types beside it, so
a new asset is filed where the panel shows it.

- Menu learns submenus: an entry with submenu instead of onSelect
  opens a second Menu right of the first, or left of it where the
  canvas ends. Its first entry lines up with the row that opened it.
- A submenu opens on hover or click. Resting on another entry swaps
  or closes it after 150 ms, so a pointer cutting across the rows on
  its way into a submenu does not lose it.
- The row of an open submenu stays lit, and a triangle pointing right
  marks every such row.
- A submenu has no backdrop and no Escape of its own. Popup takes a
  backdrop option for that. The first menu's backdrop and Escape
  close the whole stack, and picking an entry does the same.
- assetMenuCategories replaces assetMenuGroups and takes categories
  and their order from ASSET_GROUPS. The types are sorted by label.
- New test hooks __gm.menuSubmenus() and __gm.addMenuCategory().
  pickAssetType opens the category first, so the modules that pick a
  type through it need no change of their own. Modules 04, 10, 47,
  48, 53 and 54 open the category themselves where they read the menu.

Tests:
- Unit asset-menu: 4 green
- e2e 04: ALL PASSED, 63 checks. New: categories in panel order, each
  type once and sorted, submenu placement, hover swaps the submenu,
  picking closes menu and submenu
- Gate: CHECK EXIT 0, 261 files, 4040 tests
- The other modules that use the + menu did not run here

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:17:21 +02:00

1541 lines
63 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 { FIND_HALVES, HALVES_LEFT, HALVES_RIGHT, PASTE_HALVES } from './lib/pictures.mjs';
import { inView, 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. The last block, 47f,
* walks the way a user walks instead: the asset comes out of the + menu, its row opens the tab,
* and a sprite is given the drawing through its Texture row. 47g draws on that asset to measure the
* four point types of #195, 47h combines two shapes on it (#187), and 47i fills a shape with
* gradients and a repeated picture (#186).
*/
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,
press,
settle,
sleep,
shot,
showTab,
typeText,
undo,
} = 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} },
layers: [{ id: 'va-layer', name: 'Layer 1', items: ${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.layers[0].items[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);
// 47b-ii. The object wears the drawing through a shader asset (#293). Its shader names the picture
// until the mesh syncs, which is after the new revision was drawn: destroyed there, Pixi
// warned for the source and for its sampler with every single change.
const boundLogFrom = t.consoleLog.length;
await mutate(`
Object.assign(__gm.state.activeScene.objects.find((o) => o.id === 'va-object').overrides,
{ shader: 'adjust-colors', saturation: 0 })`);
await settle();
await sleep(300);
const waiting = [];
for (const fill of [0x3050a0, 0x70b030, REDRAWN.replace('#', '0x')]) {
await mutate(`
const art = __gm.state.project.assets.find((a) => a.id === 'va-art');
art.layers[0].items[0].fill = ${fill}`);
await settle();
await sleep(200);
waiting.push((await json('__gm.picturesWaiting()')).length);
}
const boundWarnings = t.consoleLog.slice(boundLogFrom).filter((line) => line.includes('BindGroup'));
const afterShaded = await json("__gm.drawnPicture('va-art')");
// Saturation 0 turns the yellow field grey: the shader is what draws the sprite here.
const grey = await at(48, 200);
const [red, green, blue] = [1, 3, 5].map((i) => parseInt(grey.slice(i, i + 2), 16));
const shaded = Math.max(red, green, blue) - Math.min(red, green, blue) < 12;
check(
'a drawing worn through a shader is redrawn without Pixi warning of a destroyed texture',
shaded &&
boundWarnings.length === 0 &&
// The replaced picture is freed once the shader took the new one: none is left lying.
waiting.every((count) => count === 0) &&
afterShaded.kept,
JSON.stringify({ grey, warnings: boundWarnings.length, waiting, file: afterShaded?.file }),
);
await mutate(`
const shadedObject = __gm.state.activeScene.objects.find((o) => o.id === 'va-object');
delete shadedObject.overrides.shader;
delete shadedObject.overrides.saturation`);
await settle();
await sleep(300);
// 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 }),
);
/** A button among the rows of the inspector, which may stand below the fold (#291). */
const clickRowButton = async (text) => {
const button = await json(`__gm.inspectorButton(${JSON.stringify(text)})`);
if (!button) throw new Error(`no button "${text}" in the inspector`);
await click(button.x, button.y);
};
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 cursorOver = async (x, y) => {
const at = await json(`__gm.vectorArt.pointAt(${x}, ${y})`);
await t.mouse('mouseMoved', at.x, at.y);
await settle();
return json('__gm.vectorArt.cursor()');
};
const overPoint = await cursorOver(180, 40);
const overShape = await cursorOver(110, 60);
check(
'over a point the cursor stays the plain arrow, inside the shape it shows the move cross',
overPoint === 'default' && overShape === 'move',
JSON.stringify({ overPoint, overShape }),
);
// The magnet is the graph's (#194): the tab has no Grid button of its own, and with it
// on a ragged drop lands on the 8 px grid. Undone, so the point is back where the next steps expect it.
const magnet = await json('__gm.vectorArt.snapButton()');
check(
'the tab carries the magnet in place of a Grid button',
magnet.w === magnet.h && !magnet.toggled && (await json(`__gm.locate('Grid')`)) === null,
JSON.stringify(magnet),
);
await click(magnet.x, magnet.y);
const snapFrom = await json(`__gm.vectorArt.pointAt(180, 40)`);
const snapTo = await json(`__gm.vectorArt.pointAt(187, 45)`);
await drag(snapFrom.x, snapFrom.y, snapTo.x, snapTo.y);
const onGrid = (await json("__gm.vectorArt.shapes('va-art')")).at(-1).rings[0].points[1];
check(
'with the magnet on, a dragged point lands on the grid',
(await json('__gm.vectorArt.snapping()')) === true && onGrid.x === 184 && onGrid.y === 48,
JSON.stringify(onGrid),
);
await shot('47-magnet');
await undo();
await click(magnet.x, magnet.y);
check('the magnet switches snapping off again', (await json('__gm.vectorArt.snapping()')) === false);
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');
// #288: a double-click on a line puts a point there and picks it, Delete takes the picked point
// away and leaves the shape, and a point's menu does the same.
const ringPoints = async () => (await json("__gm.vectorArt.shapes('va-art')")).at(-1).rings[0].points;
const lineSpot = await json(`__gm.vectorArt.pointAt(75, 75)`);
const linePoints = (await ringPoints()).length;
await click(lineSpot.x, lineSpot.y);
const onlyClicked = (await ringPoints()).length;
await sleep(500);
await doubleClick(lineSpot.x, lineSpot.y);
const withPoint = await ringPoints();
const pickedAdded = await json('__gm.vectorArt.picked()');
await press('Delete');
const withoutPoint = await ringPoints();
check(
'a click on a line adds nothing, a double-click puts a picked point there, and Delete takes it away again',
onlyClicked === linePoints &&
withPoint.length === linePoints + 1 &&
Boolean(pickedAdded) &&
near(withPoint[pickedAdded.index].x, 75) &&
near(withPoint[pickedAdded.index].y, 75) &&
withoutPoint.length === linePoints &&
(await json("__gm.vectorArt.shapes('va-art')")).length > 0,
JSON.stringify({
linePoints,
onlyClicked,
withPoint: withPoint.length,
pickedAdded,
withoutPoint: withoutPoint.length,
}),
);
const firstPoint = await json(`__gm.vectorArt.pointAt(${TRIANGLE[0].x}, ${TRIANGLE[0].y})`);
await sleep(500);
await doubleClick(firstPoint.x, firstPoint.y);
const keptPoints = (await ringPoints()).length;
await click(firstPoint.x, firstPoint.y, 'right');
const pointMenu = await json('__gm.menuRows()');
await clickText('Delete point');
const left = await ringPoints();
check(
'a double-click keeps a point; its menu offers Delete point and the four types, and takes it away',
keptPoints === 3 &&
JSON.stringify(pointMenu) ===
JSON.stringify(['Delete point', null, 'Straight', 'Corner', 'Smooth', 'Symmetric']) &&
left.length === 2 &&
near(left[0].x, 180),
JSON.stringify({ keptPoints, pointMenu, left: 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',
// By its control, which scrolls the row into view: with the TRANSFORM rows (#291) over it the
// inspector is taller than the window.
Boolean(outlined?.stroke) &&
outlined.stroke.width === 2 &&
Boolean(await json("__gm.inspectorField('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 clickRowButton('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()),
);
// A spare shape for the two checks below: the duplicate mark makes one and selects it. The
// inspector has no button that adds an empty shape (#293) – the Pen draws a new one.
const before47e = (await shapeIds()).length;
const spareCopy = async () => {
const known = await shapeIds();
const mark = await json("__gm.vectorArt.shapeDuplicateAt('va-badge')");
if (mark) await click(mark.x, mark.y);
await settle();
return (await shapeIds()).find((id) => !known.includes(id)) ?? null;
};
const added = await spareCopy();
check(
'the inspector offers no Add shape button',
added !== null &&
(await json('__gm.vectorArt.selected()')) === added &&
(await json("__gm.inspectorButton('Add shape')")) === null &&
(await json("__gm.inspectorButton('To front')")) !== null,
JSON.stringify({ added, selected: await json('__gm.vectorArt.selected()') }),
);
// The cross on its row takes that shape out again, and the selection with it.
const cross = await json(`__gm.vectorArt.shapeRemoveAt(${JSON.stringify(added)})`);
if (cross) await click(cross.x, cross.y);
await settle();
const afterCross = await shapeIds();
check(
'the cross on a shape row removes that shape and nothing else',
cross !== null &&
afterCross.length === before47e &&
!afterCross.includes(added) &&
(await json('__gm.vectorArt.selected()')) === '',
JSON.stringify({ cross, afterCross, selected: await json('__gm.vectorArt.selected()') }),
);
// Delete takes a shape away however it was picked. A row of the list leaves nothing focused, so the
// key reaches the editor's shortcuts rather than the canvas.
const spare = await spareCopy();
const spareRow = await json(`__gm.vectorArt.shapeRowAt(${JSON.stringify(spare)})`);
await click(spareRow.x + spareRow.w / 2, spareRow.y + spareRow.h / 2);
await settle();
const pickedSpare = await json('__gm.vectorArt.selected()');
await press('Delete');
await settle();
const afterDelete = await shapeIds();
check(
'Delete removes a shape picked in the list',
pickedSpare === spare && afterDelete.length === before47e && !afterDelete.includes(spare),
JSON.stringify({ spare, pickedSpare, afterDelete }),
);
// 47e-ii. The duplicate mark puts a copy right above its shape and selects it; a double-click on
// a name edits it in the row. Both are undone again, the blocks after count on the shapes.
const fieldName = (await shapeOf('va-field')).name;
const duplicateAt = await json("__gm.vectorArt.shapeDuplicateAt('va-field')");
if (duplicateAt) await click(duplicateAt.x, duplicateAt.y);
await settle();
const withCopy = await json("__gm.vectorArt.shapes('va-art')");
const copyIndex = withCopy.findIndex((shape) => shape.id === 'va-field') + 1;
check(
'the duplicate mark on a shape row puts a copy right above it and selects the copy',
duplicateAt !== null &&
withCopy.length === before47e + 1 &&
withCopy[copyIndex]?.name === `${fieldName} copy` &&
withCopy[copyIndex]?.fill === 0x20c0e8 &&
(await json('__gm.vectorArt.selected()')) === withCopy[copyIndex]?.id,
JSON.stringify({ duplicateAt, withCopy: withCopy.map((s) => s.name), copyIndex }),
);
await undo();
await settle();
// Picked first: with only the asset selected the list stands lower, under the asset's rows, and
// the first click of a double-click would move it up from under the pointer.
const pickAt = await json("__gm.vectorArt.shapeNameAt('va-field')");
if (pickAt) await click(pickAt.x, pickAt.y);
await settle();
const nameAt = await json("__gm.vectorArt.shapeNameAt('va-field')");
if (nameAt) await t.doubleClick(nameAt.x, nameAt.y);
await settle();
const renaming = await json('__gm.vectorArt.shapeRenaming()');
await press('a', { ctrl: true });
await typeText('Meadow');
await press('Enter');
await settle();
const renamed = await shapeOf('va-field');
check(
'a double-click on a shape name edits it in the row, and Enter keeps the new name',
renaming?.value === fieldName &&
renamed?.name === 'Meadow' &&
(await json('__gm.vectorArt.shapeRenaming()')) === null &&
(await shapeIds()).length === before47e,
JSON.stringify({ nameAt, renaming, renamed: renamed?.name }),
);
await undo();
await settle();
// 47f (stage 5). The way a user takes, which the blocks before took by hand: the asset is made
// from the + menu, its row opens the tab, and a sprite wears the drawing through its
// Texture row – the picture picker of #185, which until here only unit tests had seen.
// "Vector art" is the menu entry *and* the label of the open tab, so the entry is picked
// inside the menu's own box rather than by text alone.
const inOverlay = async (text) => {
const boxes = await json('__gm.overlayRect()');
const hits = (await json(`__gm.locateAll(${JSON.stringify(text)})`)) ?? [];
return (
hits.find(
(hit) =>
hit.x >= boxes.x && hit.x <= boxes.x + boxes.w && hit.y >= boxes.y && hit.y <= boxes.y + boxes.h,
) ?? null
);
};
// The same as a hook expression, for a row the menu has to be scrolled to: the lowest hit between
// the box's sides, which is the entry and not the tab above it.
const menuRow = (text) => `(() => {
const box = __gm.overlayRect();
const hits = (__gm.locateAll(${JSON.stringify(text)}) ?? []).filter(
(hit) => hit.x >= box.x && hit.x <= box.x + box.w && hit.y >= box.y,
);
return hits.sort((a, b) => a.y - b.y).at(-1) ?? null;
})()`;
// Vector art sits in the submenu of the Visuals; should that outgrow a short canvas, it scrolls,
// and the entry is scrolled to.
await clickText('+');
await clickText('Visuals');
const plusRows = (await json('__gm.menuRows()')) ?? [];
await inView(t, '__gm.overlayRect()', menuRow('Vector art'));
const entry = await inOverlay('Vector art');
check(
'the + menu offers Vector art among the Visuals',
plusRows.includes('Vector art') && Boolean(entry),
JSON.stringify({ offered: plusRows.includes('Vector art'), entry }),
);
await click(entry.x, entry.y);
await settle();
const madeId = (
await json("__gm.state.project.assets.filter((a) => a.type === 'vectorart').map((a) => a.id)")
).at(-1);
const madeRow = await json(`__gm.assetRow(${JSON.stringify(madeId)})`);
check(
'the entry makes a vector art asset, selected and standing in the panel',
madeId !== 'va-art' && (await json('__gm.state.selection?.assetId')) === madeId && Boolean(madeRow),
JSON.stringify({ madeId, row: madeRow }),
);
// Its row opens the tab on that asset – until here the tab was opened by `openDocument`.
await showTab('scene');
await doubleClick(madeRow.x, madeRow.y);
await settle();
check(
'a double-click on the row opens the Vector art tab on that asset',
(await json('__gm.vectorArt.current()')) === madeId,
JSON.stringify({ current: await json('__gm.vectorArt.current()'), wanted: madeId }),
);
// A sprite that wears nothing yet: the drawing has to be reachable from its Texture row.
const Y2 = Y - 400;
await mutate(`
const project = __gm.state.project;
const scene = __gm.state.activeScene;
${MAKE_OBJECT}
const bare = structuredClone(project.assets.find((a) => a.id === 'va-sprite'));
bare.id = 'va-bare';
bare.name = 'Bare sprite';
bare.props = { ...bare.props, texture: '' };
project.assets.push(bare);
scene.objects.push(make('va-bare-object', 'va-bare', '', ${Y2}, 'Bare object'));
__gm.state.select({ kind: 'asset', assetId: 'va-bare' })`);
await showTab('scene');
await evaluate(`__gm.lookAt(0, ${Y2})`);
await settle();
const textureRow = await json("__gm.inspectorField('Texture', true)");
await click(textureRow.x, textureRow.y);
await settle();
const offers = (await json('__gm.menuRows()')) ?? [];
// Measured against the model rather than against a fixed list: run alone the fixture holds no
// uploaded picture at all, while after module 12 it holds several. What has to hold either way
// is that every picture asset is offered by name, the drawings among them and sorted with them.
const pictures = await json(
"__gm.state.project.assets.filter((a) => a.type === 'texture' || a.type === 'vectorart')" +
'.map((a) => a.name).sort((a, b) => a.localeCompare(b))',
);
const offeredAssets = offers.filter((row) => pictures.includes(row));
check(
'the Texture row offers the drawing by name, sorted among the other pictures',
offers.includes('Emblem') &&
offeredAssets.join() === pictures.join() &&
// The row it wears right now is marked, so "None" arrives with a bullet in front of it.
offers.some((row) => typeof row === 'string' && row.endsWith('None')),
JSON.stringify({ offers, pictures }),
);
const drawingEntry = await inOverlay('Emblem');
await click(drawingEntry.x, drawingEntry.y);
await settle();
await sleep(300);
const bareBox = await json("__gm.objectRect('va-bare-object')");
const wornAt = (cx, cy) =>
json(
`__gm.pixelAt(${Math.round(bareBox.x + (cx / CANVAS - 0.5) * bareBox.w)}, ${Math.round(bareBox.y + (cy / CANVAS - 0.5) * bareBox.h)})`,
);
// The field was recoloured in 47e and sent to the back, so this is the colour of that row.
const wornPixel = await wornAt(48, 200);
check(
'picking it there makes the sprite wear the drawing, as the scene shows',
(await json("__gm.state.project.assets.find((a) => a.id === 'va-bare').props.texture")) === 'va-art' &&
colourDistance(wornPixel, '#20c0e8') < 14,
JSON.stringify({
worn: await json("__gm.state.project.assets.find((a) => a.id === 'va-bare').props.texture"),
wornPixel,
}),
);
await shot('47-picture-picker');
// 47g (#195). The pen draws a curve with two handles, and the header's point type steers what
// a dragged handle does to the other one: a corner leaves it, a smooth point turns it round
// at its own length, straight takes both away. Drawn on the asset 47f made, which is empty.
await evaluate(`__gm.openDocument('asset:${madeId}')`);
await settle();
const pointsOf = async () =>
(await json(`__gm.vectorArt.shapes(${JSON.stringify(madeId)})`)).at(-1)?.rings[0]?.points ?? [];
const screenAt = (x, y) => json(`__gm.vectorArt.pointAt(${x}, ${y})`);
const pointType = () => json('__gm.vectorArt.pointButton()');
const before195 = await pointType();
check(
'with no point picked the point type reads as none and cannot be opened',
before195.text === 'Point: –' && !before195.enabled,
JSON.stringify(before195),
);
await clickText('Pen');
const A = await screenAt(60, 160);
const B = await screenAt(128, 80);
const pulledTo = await screenAt(168, 80);
const C = await screenAt(196, 160);
await click(A.x, A.y);
await drag(B.x, B.y, pulledTo.x, pulledTo.y);
await click(C.x, C.y);
await click(A.x, A.y);
const penPoints = await pointsOf();
const curved = penPoints[1];
check(
'a drag with the pen grows both handles of the point, mirrored – a symmetric point',
penPoints.length === 3 &&
curved?.mode === 'symmetric' &&
near(curved.out?.x, 40) &&
near(curved.out?.y, 0) &&
near(curved.in?.x, -40) &&
near(curved.in?.y, 0),
JSON.stringify(penPoints),
);
await clickText('Select');
await click(B.x, B.y);
const picked = await pointType();
check(
'a click on that point picks it, and the header names its type',
picked.text === 'Point: Symmetric' && picked.enabled && picked.kind === 'symmetric',
JSON.stringify(picked),
);
const openPointMenu = async () => {
const button = await pointType();
await click(button.x, button.y);
return t.menuTicked();
};
const choose = async (label) => {
const rows = await openPointMenu();
const text = rows.find((row) => row.label === label)?.label;
const row = text && (await inOverlay(text));
if (!row) throw new Error(`no "${label}" in the point type menu: ${JSON.stringify(rows)}`);
await click(row.x, row.y);
await settle();
};
const offered = await openPointMenu();
check(
'the point type menu offers the four types and ticks the one the point is',
offered.map((row) => `${row.ticked ? '+' : '-'}${row.label}`).join('|') ===
'-Straight|-Corner|-Smooth|+Symmetric',
JSON.stringify(offered),
);
await press('Escape');
await settle();
const dragOut = async (x, y) => {
const point = (await pointsOf())[1];
const from = await screenAt(point.x + point.out.x, point.y + point.out.y);
const to = await screenAt(x, y);
await drag(from.x, from.y, to.x, to.y);
return (await pointsOf())[1];
};
await choose('Corner');
const cusp = await dragOut(128, 40);
check(
'on a corner a dragged handle leaves the other one where it was',
cusp.mode === null &&
near(cusp.out.x, 0) &&
near(cusp.out.y, -40) &&
near(cusp.in.x, -40) &&
near(cusp.in.y, 0),
JSON.stringify(cusp),
);
await choose('Smooth');
const lined = (await pointsOf())[1];
const smoothDragged = await dragOut(128 + 60, 80);
const inLength = Math.hypot(smoothDragged.in.x, smoothDragged.in.y);
check(
'Smooth lines the handles up, and dragging one turns the other round at its own length',
lined.mode === 'smooth' &&
smoothDragged.mode === 'smooth' &&
near(smoothDragged.out.x, 60) &&
near(smoothDragged.out.y, 0) &&
smoothDragged.in.x < 0 &&
near(smoothDragged.in.y, 0) &&
near(inLength, Math.hypot(lined.in.x, lined.in.y)) &&
!near(inLength, 60),
JSON.stringify({ lined, smoothDragged }),
);
await shot('47-point-smooth');
await choose('Straight');
const straight = (await pointsOf())[1];
check(
'Straight takes both handles away, and the header says so',
straight.in === null && straight.out === null && (await pointType()).text === 'Point: Straight',
JSON.stringify({ straight, header: await pointType() }),
);
// 47h (#187). Two squares are combined the way a user does it: a click picks the lower one, a
// Ctrl+click adds the upper one, and Subtract in the inspector cuts the upper out of the lower.
// The squares go into the model – drawing them is what 47c measured.
const square = (id, name, fill, x, y) => ({
id,
name,
fill,
opacity: 1,
rings: [
{
closed: true,
points: [
{ x, y },
{ x: x + 100, y },
{ x: x + 100, y: y + 100 },
{ x, y: y + 100 },
],
},
],
});
await mutate(`
const art = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(madeId)});
art.layers[0].items = ${JSON.stringify([square('va-left', 'Left', 0xcc3344, 40, 60), square('va-right', 'Right', 0x3344cc, 100, 100)])};
__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(madeId)} })`);
await settle();
const selectedIds = () => json('__gm.vectorArt.selectedIds()');
const leftOnly = await screenAt(60, 80);
const rightOnly = await screenAt(180, 180);
await click(leftOnly.x, leftOnly.y);
await settle();
check(
'a click picks one shape, and the inspector asks for a second one to combine',
JSON.stringify(await selectedIds()) === '["va-left"]' &&
(await t.has('Ctrl+click a second shape to combine.')),
JSON.stringify(await selectedIds()),
);
await click(rightOnly.x, rightOnly.y, 'left', { ctrl: true });
await settle();
check(
'Ctrl+click adds the other shape, and the inspector names the lowest as the one that stays',
JSON.stringify(await selectedIds()) === '["va-left","va-right"]' &&
(await t.has('The result keeps "Left", the lowest.')),
JSON.stringify(await selectedIds()),
);
await shot('47-combine-selected');
await clickRowButton('Subtract');
await settle();
const cut = await json(`__gm.vectorArt.shapes(${JSON.stringify(madeId)})`);
const ring = cut?.[0]?.rings[0]?.points ?? [];
const area = Math.abs(
ring.reduce((sum, p, i) => {
const q = ring[(i + 1) % ring.length];
return sum + p.x * q.y - q.x * p.y;
}, 0) / 2,
);
check(
'Subtract leaves the lower square with the corner the upper one covered cut away',
cut?.length === 1 &&
cut[0].id === 'va-left' &&
cut[0].fill === 0xcc3344 &&
cut[0].rings.length === 1 &&
ring.length === 6 &&
near(area, 100 * 100 - 40 * 60) &&
JSON.stringify(await selectedIds()) === '["va-left"]',
JSON.stringify({ cut, area, selected: await selectedIds() }),
);
// The tab paints the result: the fill where the lower square is left, none where it was cut away –
// neither red nor the blue of the upper square, which lay there before and would pass for "not red".
const keptPixel = await json(`__gm.pixelAt(${leftOnly.x}, ${leftOnly.y})`);
const inCut = await screenAt(120, 130);
const cutPixel = await json(`__gm.pixelAt(${inCut.x}, ${inCut.y})`);
check(
'the tab paints the cut: the fill remains beside it, and inside it the canvas is empty',
colourDistance(keptPixel, '#cc3344') < 14 &&
colourDistance(cutPixel, '#cc3344') > 60 &&
colourDistance(cutPixel, '#3344cc') > 60,
JSON.stringify({ keptPixel, cutPixel }),
);
await shot('47-combine-subtracted');
await undo();
await settle();
const restored = await json(`__gm.vectorArt.shapes(${JSON.stringify(madeId)})`);
check(
'one undo brings both squares back as they were',
restored?.map((s) => s.id).join(',') === 'va-left,va-right' && restored[0].rings[0].points.length === 4,
JSON.stringify(restored?.map((s) => ({ id: s.id, points: s.rings[0]?.points.length }))),
);
// 47p (#208). The same subtract, but on a circle: the side the square does not touch has to come
// back as a curve, not as the forty corners the clipping runs on.
const K = 0.5523 * 60;
const DISC = {
id: 'va-disc',
name: 'Disc',
fill: 0x22aa66,
opacity: 1,
rings: [
{
closed: true,
points: [
{ x: 180, y: 120, in: { x: 0, y: -K }, out: { x: 0, y: K } },
{ x: 120, y: 180, in: { x: K, y: 0 }, out: { x: -K, y: 0 } },
{ x: 60, y: 120, in: { x: 0, y: K }, out: { x: 0, y: -K } },
{ x: 120, y: 60, in: { x: -K, y: 0 }, out: { x: K, y: 0 } },
],
},
],
};
await mutate(`
const art = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(madeId)});
art.layers[0].items = ${JSON.stringify([DISC, square('va-cover', 'Cover', 0x3344cc, 20, 20)])};
__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(madeId)} })`);
await settle();
const onDisc = await screenAt(170, 120);
const onCover = await screenAt(40, 40);
await click(onDisc.x, onDisc.y);
await click(onCover.x, onCover.y, 'left', { ctrl: true });
await settle();
await clickRowButton('Subtract');
await settle();
const carved = await json(`__gm.vectorArt.shapes(${JSON.stringify(madeId)})`);
const arc = carved?.[0]?.rings[0]?.points ?? [];
check(
'a curve the cut did not touch comes back as a curve, not as corners (#208)',
carved?.length === 1 && arc.length > 2 && arc.length < 10 && arc.filter((p) => p.in || p.out).length >= 2,
JSON.stringify({ points: arc.length, curved: arc.filter((p) => p.in || p.out).length }),
);
// Few points and still round: the tab paints the disc's edge where the circle runs, and nothing
// where a straight line between two of those few points would have cut the corner.
const insideArc = await screenAt(160, 80);
const outsideArc = await screenAt(168, 72);
const arcPixel = await json(`__gm.pixelAt(${insideArc.x}, ${insideArc.y})`);
const pastArc = await json(`__gm.pixelAt(${outsideArc.x}, ${outsideArc.y})`);
check(
'the tab paints that side round: filled on the circle, empty just past it',
colourDistance(arcPixel, '#22aa66') < 14 && colourDistance(pastArc, '#22aa66') > 60,
JSON.stringify({ arcPixel, pastArc }),
);
await shot('47-combine-curve-kept');
// 47i (#186). Gradients and a picture fill, set the way a user sets them: the Fill type row, the
// stop bar under it, the Paint tool's handles in the tab. A two-colour picture is pasted in
// first – it is what the texture fill is measured with, red on the left and green on the right.
await evaluate(PASTE_HALVES);
await sleep(2500);
const halves = await json(FIND_HALVES);
const SKY = {
id: 'va-sky',
name: 'Sky',
fill: 0x0000ff,
opacity: 1,
rings: [
{
closed: true,
points: [
{ x: 28, y: 28 },
{ x: 228, y: 28 },
{ x: 228, y: 228 },
{ x: 28, y: 228 },
],
},
],
};
await mutate(`
const artPaint = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(madeId)});
artPaint.layers[0].items = ${JSON.stringify([SKY])};
__gm.state.select({ kind: 'shape', assetId: ${JSON.stringify(madeId)}, shapeId: 'va-sky', shapeIds: ['va-sky'] })`);
await evaluate(`__gm.openDocument('asset:${madeId}')`);
await settle();
const sky = async () => (await json(`__gm.vectorArt.shapes(${JSON.stringify(madeId)})`))?.[0] ?? null;
const tabPixel = async (x, y) => {
const atPaint = await screenAt(x, y);
return json(`__gm.pixelAt(${Math.round(atPaint.x)}, ${Math.round(atPaint.y)})`);
};
const pickFrom = async (label, text) => {
const fieldPaint = await json(`__gm.inspectorField(${JSON.stringify(label)}, true)`);
await click(fieldPaint.x, fieldPaint.y);
await settle();
const rowPaint = await inOverlay(text);
if (!rowPaint)
throw new Error(`no "${text}" under "${label}": ${JSON.stringify(await json('__gm.menuRows()'))}`);
await click(rowPaint.x, rowPaint.y);
await settle();
};
const offset = (p) => p?.stops?.map((s) => Math.round(s.offset * 100) / 100);
await pickFrom('Fill type', 'Linear gradient');
const linear = (await sky())?.paint;
const nearLeft = await tabPixel(48, 128);
const nearRight = await tabPixel(208, 128);
check(
'Linear gradient lays the axis across the shape, from its blue to white, as the tab shows',
linear?.type === 'linear' &&
near(linear.from.x, 28) &&
near(linear.to.x, 228) &&
near(linear.from.y, 128) &&
linear.stops.map((s) => s.color).join() === `${0x0000ff},${0xffffff}` &&
colourDistance(nearLeft, '#1a1aff') < 20 &&
colourDistance(nearRight, '#e6e6ff') < 20,
JSON.stringify({ linear, nearLeft, nearRight }),
);
const middle = await json("__gm.vectorArt.barAt('fill', 0.5)");
await click(middle.x, middle.y);
await settle();
const addedPaint = (await sky())?.paint;
check(
'a click on the bar adds a stop there in the colour the gradient has at that place',
addedPaint?.stops.length === 3 &&
Math.abs(addedPaint.stops[2].offset - 0.5) < 0.02 &&
colourDistance(`#${addedPaint.stops[2].color.toString(16).padStart(6, '0')}`, '#8080ff') < 6,
JSON.stringify(addedPaint?.stops),
);
const stopField = await json("__gm.inspectorField('Stop color', true)");
await click(stopField.x, stopField.y);
await press('a', { ctrl: true });
await typeText('ff0000');
await press('Enter');
await settle();
const reddened = (await sky())?.paint;
const redMiddle = await tabPixel(128, 128);
check(
'the Stop color row recolors the picked stop, and the middle of the shape turns red',
reddened?.stops[2]?.color === 0xff0000 && colourDistance(redMiddle, '#ff0000') < 24,
JSON.stringify({ stops: reddened?.stops, redMiddle }),
);
const marker = await json("__gm.vectorArt.stopAt('fill', 2)");
const quarter = await json("__gm.vectorArt.barAt('fill', 0.25)");
await drag(marker.x, marker.y, quarter.x, marker.y, { steps: 8 });
await settle();
const draggedStops = offset((await sky())?.paint);
const redQuarter = await tabPixel(78, 128);
await undo();
await settle();
const undoneStops = offset((await sky())?.paint);
check(
'dragging the marker moves the stop along the bar in one undo step',
Math.abs(draggedStops?.[2] - 0.25) <= 0.02 &&
colourDistance(redQuarter, '#ff0000') < 24 &&
Math.abs(undoneStops?.[2] - 0.5) <= 0.02,
JSON.stringify({ draggedStops, redQuarter, undoneStops }),
);
const paintTool = await json('__gm.vectorArt.paintButton()');
await click(paintTool.x, paintTool.y);
await settle();
const axisEnd = await screenAt(228, 128);
const pulledIn = await screenAt(128, 128);
await drag(axisEnd.x, axisEnd.y, pulledIn.x, pulledIn.y, { steps: 8 });
await settle();
const shortened = (await sky())?.paint;
const pastEnd = await tabPixel(178, 128);
check(
'the Paint tool drags the end of the axis, and past it the last colour holds',
(await json('__gm.vectorArt.tool()')) === 'paint' &&
near(shortened?.to.x, 128) &&
near(shortened?.to.y, 128) &&
colourDistance(pastEnd, '#ffffff') < 20,
JSON.stringify({ to: shortened?.to, pastEnd }),
);
await pickFrom('Fill type', 'Radial gradient');
const radial = (await sky())?.paint;
const ringPaint = await tabPixel(128, 150);
const cornerPaint = await tabPixel(40, 40);
check(
'Radial gradient keeps the three colours and runs from the centre out to the farther side',
radial?.type === 'radial' &&
radial.stops.length === 3 &&
near(radial.from.x, 128) &&
near(radial.from.y, 128) &&
near(radial.to.x, 228) &&
colourDistance(ringPaint, '#70008f') < 30 &&
colourDistance(cornerPaint, '#ffffff') < 20,
JSON.stringify({ radial, ringPaint, cornerPaint }),
);
await shot('47-paint-radial');
await pickFrom('Fill type', 'Texture');
const textureRowPaint = await json("__gm.inspectorField('Texture', true)");
await click(textureRowPaint.x, textureRowPaint.y);
await settle();
const pictureOffers = (await json('__gm.menuRows()')) ?? [];
const halvesName = await json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(halves?.id ?? '')})?.name ?? null`,
);
// Picked through the search box: after the earlier modules the list holds 30-odd pictures, and
// an entry below the fold of the capped menu cannot be clicked. Enter takes the first match; a
// click would hit the typed name in the search box first.
if (halvesName) {
await typeText(halvesName);
await settle();
}
const filteredOffers = (await json('__gm.menuRows()')) ?? [];
const halvesEntry = Boolean(halvesName) && filteredOffers.includes(halvesName);
await press(halvesEntry ? 'Enter' : 'Escape');
await settle();
// The picture loads asynchronously and the shape keeps its plain fill until it is there; the tab
// redraws by itself when it lands. Nothing here asks for that redraw: after module 45 had signed
// out and in, a canvas of the old editor used to swallow the news (#196).
let leftHalf = await tabPixel(60, 100);
let rightHalf = await tabPixel(196, 100);
// Both halves, or the picture may land between the two reads and leave the left one stale.
const landed = () =>
colourDistance(leftHalf, HALVES_LEFT) < 20 && colourDistance(rightHalf, HALVES_RIGHT) < 20;
for (let waited = 0; waited < 3000 && !landed(); waited += 100) {
await sleep(100);
leftHalf = await tabPixel(60, 100);
rightHalf = await tabPixel(196, 100);
}
const tiled = (await sky())?.paint;
check(
'Texture offers the uploaded pictures but no drawing, and fills the shape with the one picked',
halvesEntry &&
!pictureOffers.includes('Emblem') &&
tiled?.type === 'texture' &&
tiled.texture === halves?.id &&
colourDistance(leftHalf, HALVES_LEFT) < 20 &&
colourDistance(rightHalf, HALVES_RIGHT) < 20,
JSON.stringify({ filteredOffers, tiled, leftHalf, rightHalf }),
);
const tileCorner = await screenAt(228, 228);
const tileHalf = await screenAt(128, 128);
await drag(tileCorner.x, tileCorner.y, tileHalf.x, tileHalf.y, { steps: 8 });
await settle();
await sleep(200);
const smaller = (await sky())?.paint;
const secondRed = await tabPixel(153, 100);
const secondGreen = await tabPixel(203, 100);
check(
'a smaller tile repeats the picture: the next tile starts red again',
near(smaller?.to.x, 128) &&
near(smaller?.to.y, 128) &&
colourDistance(secondRed, HALVES_LEFT) < 20 &&
colourDistance(secondGreen, HALVES_RIGHT) < 20,
JSON.stringify({ to: smaller?.to, secondRed, secondGreen }),
);
await shot('47-paint-texture');
// The same drawing on a sprite in the scene: the rasteriser draws the picture fill too – it waits
// for the picture instead of keeping a drawing without it.
await mutate(`
__gm.state.project.assets.find((a) => a.id === 'va-bare').props.texture = ${JSON.stringify(madeId)};
__gm.state.select(null)`);
await showTab('scene');
await evaluate(`__gm.lookAt(0, ${Y2})`);
await settle();
await sleep(400);
const skyBox = await json("__gm.objectRect('va-bare-object')");
const skyWorn = (cx, cy) =>
json(
`__gm.pixelAt(${Math.round(skyBox.x + (cx / CANVAS - 0.5) * skyBox.w)}, ${Math.round(skyBox.y + (cy / CANVAS - 0.5) * skyBox.h)})`,
);
const wornRed = await skyWorn(153, 100);
const wornGreen = await skyWorn(203, 100);
check(
'a sprite wearing the drawing shows the repeated picture in the scene',
colourDistance(wornRed, HALVES_LEFT) < 24 && colourDistance(wornGreen, HALVES_RIGHT) < 24,
JSON.stringify({ wornRed, wornGreen }),
);
// The outline gets a gradient of its own, and the header's Handles menu switches the tool to it.
await evaluate(`__gm.openDocument('asset:${madeId}')`);
await mutate(
`__gm.state.select({ kind: 'shape', assetId: ${JSON.stringify(madeId)}, shapeId: 'va-sky', shapeIds: ['va-sky'] })`,
);
await settle();
const outlineSwitch = await json("__gm.inspectorField('Outline', true)");
await click(outlineSwitch.x, outlineSwitch.y);
await settle();
await pickFrom('Line type', 'Linear gradient');
const handles = await json('__gm.vectorArt.paintTarget()');
await click(handles.x, handles.y);
await settle();
const outlineRow = ((await json('__gm.menuRows()')) ?? []).find((rowPaint) => rowPaint === 'Outline');
const outlineEntry = outlineRow && (await inOverlay(outlineRow));
if (outlineEntry) await click(outlineEntry.x, outlineEntry.y);
await settle();
const lineEnd = await screenAt(228, 128);
const lineUp = await screenAt(228, 28);
await drag(lineEnd.x, lineEnd.y, lineUp.x, lineUp.y, { steps: 8 });
await settle();
const linedPaint = await sky();
check(
'the outline takes a gradient, and with Handles on Outline the tool moves its end, not the fill',
(await json('__gm.vectorArt.paintTarget()')).text === 'Handles: Outline' &&
linedPaint?.stroke?.paint?.type === 'linear' &&
near(linedPaint.stroke.paint.to.x, 228) &&
near(linedPaint.stroke.paint.to.y, 28) &&
near(linedPaint.paint?.to.x, 128) &&
near(linedPaint.paint?.to.y, 128),
JSON.stringify({ stroke: linedPaint?.stroke, fillTo: linedPaint?.paint?.to }),
);
await shot('47-paint-outline');
// 47j. Layers and groups (#190): three squares, grouped, moved as one, and a layer hidden.
const RED = '#cc3344';
const BLUE = '#3344cc';
const GREEN = '#33aa55';
await mutate(`
const art190 = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(madeId)});
art190.layers = [{ id: 'va-base', name: 'Layer 1', items: ${JSON.stringify([
{ ...square('va-one', 'One', 0xcc3344, 10, 10), rings: [squareRing(10, 10, 60)] },
{ ...square('va-two', 'Two', 0x3344cc, 90, 10), rings: [squareRing(90, 10, 60)] },
{ ...square('va-three', 'Three', 0x33aa55, 170, 10), rings: [squareRing(170, 10, 60)] },
])} }];
__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(madeId)} })`);
await evaluate(`__gm.openDocument('asset:${madeId}')`);
await clickText('Select');
await settle();
const layers190 = () => json(`__gm.vectorArt.layers(${JSON.stringify(madeId)})`);
const rowIds = (layer) => layer.rows.map((r) => `${r.id}:${r.depth}`).join(' ');
const cornerOf = async (id) =>
(await json(`__gm.vectorArt.shapes(${JSON.stringify(madeId)})`)).find((s) => s.id === id)?.rings[0]
.points[0];
const rowCentre = async (id) => {
const r = await json(`__gm.vectorArt.shapeRowAt(${JSON.stringify(id)})`);
return { x: r.x + r.w / 2, y: r.y + r.h / 2, h: r.h };
};
// Two shapes picked in the tab, then the Group button: one group where the upper one stood.
// The second is added with Shift, which does what Ctrl does in 47g.
const onOne = await screenAt(40, 40);
const onTwo = await screenAt(120, 40);
await click(onOne.x, onOne.y);
await click(onTwo.x, onTwo.y, 'left', { shift: true });
await settle();
await clickRowButton('Group');
await settle();
const grouped = (await layers190())[0];
const groupId = grouped.rows.find((r) => r.group)?.id;
const nameX = async (id) => (await json(`__gm.vectorArt.shapeNameAt(${JSON.stringify(id)})`)).x;
const indent = (await nameX('va-two')) - (await nameX('va-three'));
check(
'Group puts the two selected shapes into a group, its rows one step in',
rowIds(grouped) === `va-three:0 ${groupId}:0 va-two:1 va-one:1` &&
(await nameX(groupId)) === (await nameX('va-three')) &&
indent === 14 &&
(await json('__gm.vectorArt.selectedIds()')).join() === 'va-one,va-two',
JSON.stringify({ rows: grouped.rows, indent, selected: await json('__gm.vectorArt.selectedIds()') }),
);
await shot('47-groups');
// The keys do the same from wherever the focus is: Ctrl+Shift+G takes the group away, Ctrl+G
// puts the same two shapes into a new one.
await press('g', { ctrl: true, shift: true });
await settle();
const ungrouped = rowIds((await layers190())[0]);
await press('g', { ctrl: true });
await settle();
const regrouped = (await layers190())[0];
check(
'Ctrl+Shift+G takes the group away and Ctrl+G groups the selection again',
ungrouped === 'va-three:0 va-two:0 va-one:0' &&
regrouped.rows.map((r) => `${r.name}:${r.depth}`).join(' ') === 'Three:0 Group 1:0 Two:1 One:1',
JSON.stringify({ ungrouped, regrouped: regrouped.rows }),
);
await undo();
await undo();
await settle();
// A press on one shape of the group takes the whole group, and a drag moves both in one step.
const onThree = await screenAt(200, 40);
await click(onThree.x, onThree.y);
await settle();
const alone = await json('__gm.vectorArt.selectedIds()');
const lower = await screenAt(40, 140);
await drag(onOne.x, onOne.y, lower.x, lower.y, { steps: 8 });
await settle();
const movedOne = await cornerOf('va-one');
const movedTwo = await cornerOf('va-two');
const tookGroup = await json('__gm.vectorArt.selectedIds()');
const redBelow = await tabPixel(40, 140);
const blueBelow = await tabPixel(120, 140);
await undo();
await settle();
const backOne = await cornerOf('va-one');
const backTwo = await cornerOf('va-two');
check(
'dragging one shape of a group moves the whole group, and one undo puts both back',
alone.join() === 'va-three' &&
tookGroup.join() === 'va-one,va-two' &&
near(movedOne.y, 110) &&
near(movedTwo.y, 110) &&
near(movedTwo.x, 90) &&
colourDistance(redBelow, RED) < 14 &&
colourDistance(blueBelow, BLUE) < 14 &&
near(backOne.y, 10) &&
near(backTwo.y, 10),
JSON.stringify({ alone, tookGroup, movedOne, movedTwo, redBelow, blueBelow, backOne, backTwo }),
);
// A row dragged under the group's last row: straight down it joins the group, the row's own
// depth being 0 it stays outside – so it is dragged one indent to the right as well.
const threeRow = await rowCentre('va-three');
const oneRow = await rowCentre('va-one');
await drag(threeRow.x, threeRow.y, threeRow.x + 14, oneRow.y + oneRow.h / 2 - 2, { steps: 8 });
await settle();
const joined = (await layers190())[0];
check(
'a row dragged to the end of a group and one indent right joins the group',
rowIds(joined) === `${groupId}:0 va-two:1 va-one:1 va-three:1`,
JSON.stringify(joined.rows),
);
await undo();
await settle();
// The plus of the layer list: a second layer over the first, which the pen then draws onto.
const plus190 = await json('__gm.vectorArt.layerAddAt()');
await click(plus190.x, plus190.y);
await settle();
const twoLayers = await layers190();
const upperId = twoLayers[1]?.id;
check(
'the plus adds a layer on top, and it is the one worked on',
twoLayers.length === 2 &&
twoLayers[1].active &&
twoLayers[1].rows.length === 0 &&
(await json('__gm.vectorArt.layerRows()')).join() === `${upperId},va-base`,
JSON.stringify(twoLayers.map((l) => ({ id: l.id, active: l.active, rows: l.rows.length }))),
);
// The eye of the lower layer: its shapes leave the tab, and a press there finds nothing.
const eye = await json("__gm.vectorArt.layerEyeAt('va-base')");
await click(eye.x, eye.y);
await settle();
const hiddenRed = await tabPixel(40, 40);
await click(onOne.x, onOne.y);
await settle();
const pressedHidden = await json('__gm.vectorArt.selectedIds()');
await shot('47-layer-hidden');
const eyeAgain = await json("__gm.vectorArt.layerEyeAt('va-base')");
await click(eyeAgain.x, eyeAgain.y);
await settle();
const shownRed = await tabPixel(40, 40);
check(
'the eye hides a layer in the tab, its shapes cannot be pressed, and the eye shows it again',
colourDistance(hiddenRed, RED) > 60 &&
pressedHidden.length === 0 &&
(await layers190())[0].hidden === false &&
colourDistance(shownRed, RED) < 14 &&
colourDistance(await tabPixel(200, 40), GREEN) < 14,
JSON.stringify({ hiddenRed, pressedHidden, shownRed }),
);
// 47k. The frame round the selection (#291): its handles scale and turn what is selected, the
// rows of the inspector do the same by number, and two shapes that are not grouped go together.
await mutate(`
const art291 = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(madeId)});
art291.layers = [{ id: 'va-base', name: 'Layer 1', items: ${JSON.stringify([
{ ...square('va-one', 'One', 0xcc3344, 10, 10), rings: [squareRing(10, 90, 60)] },
{ ...square('va-two', 'Two', 0x3344cc, 90, 10), rings: [squareRing(90, 90, 60)] },
{ ...square('va-three', 'Three', 0x33aa55, 170, 10), rings: [squareRing(170, 90, 60)] },
])} }];
__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(madeId)} })`);
await settle();
const frame291 = () => json('__gm.vectorArt.frame()');
const pointsOf291 = async (id) =>
(await json(`__gm.vectorArt.shapes(${JSON.stringify(madeId)})`))
.find((s) => s.id === id)
?.rings[0].points.map((p) => [p.x, p.y]);
const reads291 = (points, expected) =>
points?.length === expected.length &&
points.every((p, i) => near(p[0], expected[i][0]) && near(p[1], expected[i][1]));
const zoom291 = (await screenAt(1, 0)).x - (await screenAt(0, 0)).x;
// The squares stand in the middle of the canvas: the knob over a frame at its top edge would
// lie outside the tab.
const one291 = await screenAt(40, 120);
const two291 = await screenAt(120, 120);
// One shape picked: the frame stands off its bounds, and its far corner pulled 60 pixels out
// doubles the square from the corner opposite.
const noFrame = await frame291();
await click(one291.x, one291.y);
await settle();
const framed = await frame291();
const cornerOne = await screenAt(70, 150);
const handle = framed.corners[2];
await shot('47-frame');
await drag(handle.x, handle.y, handle.x + 60 * zoom291, handle.y + 60 * zoom291, { steps: 8 });
await settle();
const scaledOne = await pointsOf291('va-one');
const redGrown = await tabPixel(110, 190);
await undo();
await settle();
const unscaledOne = await pointsOf291('va-one');
check(
'a corner of the frame scales the selected shape from the opposite corner, in one undo step',
noFrame === null &&
near(handle.x - cornerOne.x, 15) &&
near(handle.y - cornerOne.y, 15) &&
framed.edges.length === 4 &&
reads291(scaledOne, [
[10, 90],
[130, 90],
[130, 210],
[10, 210],
]) &&
colourDistance(redGrown, RED) < 14 &&
reads291(unscaledOne, [
[10, 90],
[70, 90],
[70, 150],
[10, 150],
]),
JSON.stringify({ noFrame, handle, cornerOne, scaledOne, redGrown, unscaledOne }),
);
// Two shapes picked without a group (#217): a drag on one moves both by the same way.
await click(two291.x, two291.y, 'left', { shift: true });
await settle();
const pair = await json('__gm.vectorArt.selectedIds()');
await drag(one291.x, one291.y, one291.x, one291.y + 80 * zoom291, { steps: 8 });
await settle();
const pairOne = await pointsOf291('va-one');
const pairTwo = await pointsOf291('va-two');
const pairThree = await pointsOf291('va-three');
const blueMoved = await tabPixel(120, 200);
await undo();
await settle();
check(
'two selected shapes that are not grouped are dragged together, and one undo puts both back',
pair.join() === 'va-one,va-two' &&
near(pairOne?.[0][1], 170) &&
near(pairTwo?.[0][1], 170) &&
near(pairTwo?.[0][0], 90) &&
near(pairThree?.[0][1], 90) &&
colourDistance(blueMoved, BLUE) < 14 &&
near((await pointsOf291('va-one'))?.[0][1], 90) &&
near((await pointsOf291('va-two'))?.[0][1], 90),
JSON.stringify({ pair, pairOne, pairTwo, pairThree, blueMoved }),
);
// The knob with Shift held: from above the middle to the right of it, a quarter turn of both
// shapes round the middle of their common frame, which is at 80, 120.
const both = await frame291();
const middle291 = await screenAt(80, 120);
const reach = middle291.y - both.knob.y;
const emptyBefore = await tabPixel(80, 160);
await drag(both.knob.x, both.knob.y, middle291.x + reach, middle291.y + 6, {
steps: 10,
modifiers: { shift: true },
});
await settle();
const turnedOne = await pointsOf291('va-one');
const turnedTwo = await pointsOf291('va-two');
const blueTurned = await tabPixel(80, 160);
const upright = await frame291();
await shot('47-frame-turned');
await undo();
await settle();
check(
'the knob turns the selection round the middle of its frame, snapping to a quarter with Shift',
reads291(turnedOne, [
[110, 50],
[110, 110],
[50, 110],
[50, 50],
]) &&
reads291(turnedTwo, [
[110, 130],
[110, 190],
[50, 190],
[50, 130],
]) &&
colourDistance(emptyBefore, BLUE) > 60 &&
colourDistance(blueTurned, BLUE) < 14 &&
// Baked in: the frame is upright again, its knob over the middle of the turned pair.
near(upright.knob.x, middle291.x) &&
upright.knob.y < middle291.y &&
near((await pointsOf291('va-one'))?.[0][0], 10),
JSON.stringify({
turnedOne,
turnedTwo,
emptyBefore,
blueTurned,
upright: upright.knob,
middle: middle291,
}),
);
// The rows of the inspector: a typed width stretches both shapes from the left edge, and a
// typed turn is baked in, after which its row reads 0 again.
const widthField = await json("__gm.inspectorField('Width', true)");
await click(widthField.x, widthField.y);
await press('a', { ctrl: true });
await typeText('280');
await press('Enter');
await settle();
const wideOne = await pointsOf291('va-one');
const wideTwo = await pointsOf291('va-two');
await undo();
await settle();
const turnField = await json("__gm.inspectorField('Rotate by', true)");
await click(turnField.x, turnField.y);
await press('a', { ctrl: true });
await typeText('180');
await press('Enter');
await settle();
const halfOne = await pointsOf291('va-one');
const turnText = await json("__gm.inspectorLink('Rotate by')?.field?.value ?? null");
await undo();
await settle();
check(
'a typed width stretches the selection and a typed turn is baked in, each one undo step',
reads291(wideOne, [
[10, 90],
[130, 90],
[130, 150],
[10, 150],
]) &&
near(wideTwo?.[0][0], 170) &&
near(wideTwo?.[1][0], 290) &&
reads291(halfOne, [
[150, 150],
[90, 150],
[90, 90],
[150, 90],
]) &&
Number(turnText) === 0 &&
near((await pointsOf291('va-one'))?.[0][0], 10),
JSON.stringify({ wideOne, wideTwo, halfOne, turnText }),
);
}
/** A square ring of `size` pixels with its corner at `x`, `y`. */
function squareRing(x, y, size) {
return {
closed: true,
points: [
{ x, y },
{ x: x + size, y },
{ x: x + size, y: y + size },
{ x, y: y + size },
],
};
}