Name, opacity and Z each sat in a separate field below or beside
the list. You had to select a row first and then look elsewhere to
change it. Duplicating was a separate button or not possible at all.
Now the row itself carries everything.
- SortableList gets three optional callbacks. onDuplicate shows a
copy button between the eye and the delete button. onRename opens
a text field on double click, Enter or a click outside commits,
Esc cancels, an empty name is ignored. value puts a NumberField
into the row.
- Dragging on the number changes the value and never moves the row.
A whole drag is one undo step, the canvas follows live.
- Copy and delete only show on the hovered and the selected row.
Their space stays reserved, so nothing jumps on hover.
- Pixel layers: copy right above with the same pixels on all
frames, rename, opacity in percent. The Name and Opacity fields
below the list are gone.
- Pixel frames: copy right after, replaces the Duplicate button.
- Tilemap layers: copy with cells, Z and visibility, rename, Z in
the row. The Name and Z fields in the panel are gone.
- Vector shapes: copy at the same place and select it, rename.
- New in core: DuplicateVectorShapeCommand,
DuplicateTilemapLayerCommand, withLayerDuplicated and copyName
("X copy", "X copy 2"). copyName lives in src/core/util/names.ts
because the model never imports from commands/.
Findings:
- Double clicking a shape that is not selected yet only selects it.
The first click moves the list up, so the second click misses the
name. Module 47 selects the row first.
Tests:
- ./check.sh: 138 files, 2055 tests, CHECK EXIT 0 (before 2052).
- GM_STEPS=47,48 (fresh, twice): ALL PASSED, 91 checks (before 86).
- GM_STEPS=39,40,47,48: module 40 green, 47 died on the rename
check, which was a test fault and is fixed.
- Counter probes: a constant copyName fails the shape and pixel
layer tests. A dead copy hit fails the copy check in 47.
- Check total 1272 in README and wiki, computed (1266 + 6).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1006 lines
41 KiB
JavaScript
1006 lines
41 KiB
JavaScript
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,
|
||
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()') }),
|
||
);
|
||
|
||
// 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()') }),
|
||
);
|
||
|
||
// 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),
|
||
);
|
||
|
||
// 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 }))),
|
||
);
|
||
// 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.shapes = ${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 colour', 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 colour row recolours 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.endsWith(' 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');
|
||
}
|