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. 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, and 47h combines two shapes on it (#187). */ 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, 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} }, 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), ); // The magnet is the graph's (#194): the tab has no Grid button of its own any more, 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'); 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()') }), ); // 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 ); }; await clickText('+'); const plusRows = (await json('__gm.menuRows()')) ?? []; const entry = await inOverlay('Vector art'); check( 'the + menu offers Vector art among the types that only live in the library', 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), ); // The menu ticks its entries in the text itself, so a row reads "☐ Corner" or "☑ Corner". const openPointMenu = async () => { const button = await pointType(); await click(button.x, button.y); return (await json('__gm.menuRows()')) ?? []; }; const choose = async (label) => { const rows = await openPointMenu(); const text = rows.find((row) => row.endsWith(` ${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.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.shapes = ${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 clickText('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 }))), ); }