import { colourDistance } from '../lib/harness.mjs'; import { FIND_HALVES, HALVES_LEFT, HALVES_RIGHT, PASTE_HALVES } from './lib/pictures.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, 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); // 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()), ); const before47e = (await shapeIds()).length; await clickRowButton('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()') }), ); // 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.at(-1))})`); 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.at(-1)) && (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. await clickRowButton('Add shape'); await settle(); const spare = (await shapeIds()).at(-1); 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 ); }; 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), ); 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 }, ], }; }