The pen only pulled out the outgoing handle of a new point, because the opposite handle was mirrored only if it already existed. The first segment of every dragged point was therefore half a curve. Beyond that bug, a point had no way to say how its handles relate. - A vector point has an optional mode: straight, corner, smooth or symmetric. Missing means corner. The mode is not part of the fingerprint, because it changes no pixel, only the next drag. - Dragging a handle follows the mode. Corner leaves the other handle alone, smooth turns it along and keeps its length, symmetric mirrors it exactly. - The pen pulls out both handles mirrored and marks the point symmetric. A plain click still sets a point without handles. - The tab header has a "Point: ..." menu for the last clicked or set point. That point is drawn filled. Changing its type is one undo step. Text instead of four icons, because the UI kit has no tooltips. - Switching a point without handles to a curved type first grows handles along its neighbours. Smooth and symmetric line the handles up and keep the rough direction of the curve. - A point inserted on a curve is smooth. Symmetric neighbours become smooth, because the split shortens one of their handles. - Alt-click still toggles between straight and symmetric. Found on the way: the old Alt rounding took the point at the other end of an open line as a neighbour, so its handles came out skewed. Fixed and held by a unit test. Existing points with handles now count as corner. Before, every drag mirrored. No pixel changes, only the next drag behaves differently. Tests: - ./check.sh: 129 files, 1917 tests, exit 0 (before 1903) - GM_STEPS=47, freshly seeded: ALL PASSED, 39 checks (before 32) - Check sum 1195 in README and wiki is computed (1188 + 7), CI measures it - Counter-checks: pen without 'symmetric' fails 3 checks, no smooth branch fails 1 unit test and 1 e2e check Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
594 lines
23 KiB
JavaScript
594 lines
23 KiB
JavaScript
import { colourDistance } from '../lib/harness.mjs';
|
||
import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
|
||
|
||
/**
|
||
* Vector art (#129), stages 2 and 3: the drawing is rasterised and travels the picture path of #185. What
|
||
* only a running editor can show – the shapes are painted by the GPU, so what a check reads here is
|
||
* the picture itself: the fill of a shape, the hole a ring inside it cuts, which of two shapes lies
|
||
* on top, the outline along a ring, and that a changed drawing becomes a new picture while the old
|
||
* one is let go.
|
||
*
|
||
* The shapes of the first blocks are put into the model rather than drawn with the mouse: those
|
||
* checks are about the rasteriser, not about the tool. From 47c on it is the tool that is measured –
|
||
* the pen puts points down, the arrow drags one and takes one away, and what comes out is read from
|
||
* the model, which is the only place a point's coordinates exist exactly. The last block, 47f,
|
||
* walks the way a user walks instead: the asset comes out of the + menu, its row opens the tab,
|
||
* and a sprite is given the drawing through its Texture row. 47g draws on that asset to measure the
|
||
* four point types of #195.
|
||
*/
|
||
|
||
const CANVAS = 256;
|
||
const FIELD = '#2e8b57';
|
||
const BADGE = '#dd4433';
|
||
const OUTLINE = '#1133ee';
|
||
const REDRAWN = '#e8c020';
|
||
|
||
/** The square field with a square hole in it, and a badge with an outline drawn over it. */
|
||
const SHAPES = [
|
||
{
|
||
id: 'va-field',
|
||
name: 'Field',
|
||
fill: 0x2e8b57,
|
||
opacity: 1,
|
||
rings: [
|
||
{
|
||
closed: true,
|
||
points: [
|
||
{ x: 16, y: 16 },
|
||
{ x: 240, y: 16 },
|
||
{ x: 240, y: 240 },
|
||
{ x: 16, y: 240 },
|
||
],
|
||
},
|
||
{
|
||
closed: true,
|
||
points: [
|
||
{ x: 96, y: 96 },
|
||
{ x: 160, y: 96 },
|
||
{ x: 160, y: 160 },
|
||
{ x: 96, y: 160 },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: 'va-badge',
|
||
name: 'Badge',
|
||
fill: 0xdd4433,
|
||
opacity: 1,
|
||
stroke: { color: 0x1133ee, width: 10 },
|
||
rings: [
|
||
{
|
||
closed: true,
|
||
points: [
|
||
{ x: 176, y: 32 },
|
||
{ x: 232, y: 32 },
|
||
{ x: 232, y: 88 },
|
||
{ x: 176, y: 88 },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
];
|
||
|
||
export async function vectorArt(t) {
|
||
const {
|
||
check,
|
||
click,
|
||
doubleClick,
|
||
drag,
|
||
clickText,
|
||
json,
|
||
evaluate,
|
||
locate,
|
||
mutate,
|
||
press,
|
||
settle,
|
||
sleep,
|
||
shot,
|
||
showTab,
|
||
typeText,
|
||
undo,
|
||
} = t;
|
||
const Y = LANES.vectorArt;
|
||
|
||
// 47a. A drawing on a sprite: what the rasteriser put on the GPU, read off the screen.
|
||
await mutate(`
|
||
const project = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
${MAKE_OBJECT}
|
||
project.assets.push({ id: 'va-art', type: 'vectorart', name: 'Emblem',
|
||
props: { canvasWidth: ${CANVAS}, canvasHeight: ${CANVAS} }, shapes: ${JSON.stringify(SHAPES)} });
|
||
const sprite = structuredClone(project.assets.find((a) => a.id === 'crate'));
|
||
sprite.id = 'va-sprite';
|
||
sprite.name = 'Emblem sprite';
|
||
sprite.props = { ...sprite.props, width: ${CANVAS}, height: ${CANVAS}, color: 0xffffff,
|
||
texture: 'va-art', shader: '' };
|
||
project.assets.push(sprite);
|
||
scene.objects.push(make('va-object', 'va-sprite', '', ${Y}, 'Emblem object'));
|
||
__gm.state.select(null)`);
|
||
await showTab('scene');
|
||
await evaluate(`__gm.lookAt(0, ${Y})`);
|
||
await settle();
|
||
await sleep(300);
|
||
|
||
const picture = await json("__gm.drawnPicture('va-art')");
|
||
check(
|
||
'the drawing lies on the GPU at twice its canvas, and measures the canvas',
|
||
Boolean(picture) &&
|
||
picture.width === CANVAS &&
|
||
picture.height === CANVAS &&
|
||
picture.pixelWidth === CANVAS * 2 &&
|
||
picture.pixelHeight === CANVAS * 2 &&
|
||
picture.kept,
|
||
JSON.stringify(picture),
|
||
);
|
||
|
||
const box = await json("__gm.objectRect('va-object')");
|
||
check('the sprite wearing the drawing is on screen', Boolean(box) && box.w > 32, JSON.stringify(box));
|
||
if (!box) return;
|
||
/**
|
||
* A point of the canvas, in canvas pixels, as the colour on screen. `objectRect` answers with the
|
||
* middle of the object, not its corner, so the canvas is measured out from there.
|
||
*/
|
||
const at = (cx, cy) =>
|
||
json(
|
||
`__gm.pixelAt(${Math.round(box.x + (cx / CANVAS - 0.5) * box.w)}, ${Math.round(box.y + (cy / CANVAS - 0.5) * box.h)})`,
|
||
);
|
||
|
||
const field = await at(48, 200);
|
||
check('the sprite shows the fill of the first shape', colourDistance(field, FIELD) < 14, field);
|
||
|
||
const outside = await json(`__gm.pixelAt(${Math.round(box.x - box.w)}, ${Math.round(box.y)})`);
|
||
// Off the middle of the hole: the scene's grid lines cross exactly there, and a grid line is not
|
||
// the background either – the check would then read the wrong colour for the right reason.
|
||
const hole = await at(112, 148);
|
||
check(
|
||
'the ring inside the shape is a hole – the scene shows through it',
|
||
colourDistance(hole, outside) < 14 && colourDistance(hole, FIELD) > 40,
|
||
JSON.stringify({ hole, outside }),
|
||
);
|
||
|
||
const badge = await at(204, 60);
|
||
check('the shape drawn later lies over the one before it', colourDistance(badge, BADGE) < 14, badge);
|
||
|
||
const outline = await at(176, 60);
|
||
check('the outline is drawn along the ring', colourDistance(outline, OUTLINE) < 24, outline);
|
||
|
||
await shot('47-vector-art');
|
||
|
||
// 47b. A changed drawing is a new picture (#185): the key moves on, the old one is let go.
|
||
const before = picture?.file;
|
||
await mutate(`
|
||
const art = __gm.state.project.assets.find((a) => a.id === 'va-art');
|
||
art.shapes[0].fill = 0x${REDRAWN.slice(1)}`);
|
||
await settle();
|
||
await sleep(300);
|
||
const after = await json("__gm.drawnPicture('va-art')");
|
||
const oldOne = before ? await json(`__gm.textureFilter(${JSON.stringify(before)})`) : null;
|
||
check(
|
||
'a changed drawing gets a key of its own and the old picture is freed',
|
||
Boolean(after) && after.file !== before && after.kept && oldOne === null,
|
||
JSON.stringify({ before, after: after?.file, oldOne }),
|
||
);
|
||
const repainted = await at(48, 200);
|
||
check('the sprite shows the changed fill', colourDistance(repainted, REDRAWN) < 14, repainted);
|
||
|
||
// 47c. The tab: the pen draws a triangle and closes it.
|
||
await evaluate("__gm.openDocument('asset:va-art')");
|
||
await settle();
|
||
const info = await json('__gm.vectorArt.info()');
|
||
check(
|
||
'the Vector art tab shows the drawing and its canvas',
|
||
(await json('__gm.vectorArt.current()')) === 'va-art' && info.startsWith(`${CANVAS} \u00d7 ${CANVAS} px`),
|
||
JSON.stringify({ current: await json('__gm.vectorArt.current()'), info }),
|
||
);
|
||
|
||
await clickText('Pen');
|
||
check('the pen is the tool a press uses', (await json('__gm.vectorArt.tool()')) === 'pen');
|
||
|
||
const TRIANGLE = [
|
||
{ x: 40, y: 40 },
|
||
{ x: 110, y: 40 },
|
||
{ x: 110, y: 110 },
|
||
];
|
||
const screen = [];
|
||
for (const point of TRIANGLE) {
|
||
const at = await json(`__gm.vectorArt.pointAt(${point.x}, ${point.y})`);
|
||
screen.push(at);
|
||
await click(at.x, at.y);
|
||
}
|
||
// The first point again: that is what closes a line, and the pen has nothing open afterwards.
|
||
await click(screen[0].x, screen[0].y);
|
||
await settle();
|
||
|
||
const drawn = (await json("__gm.vectorArt.shapes('va-art')")).at(-1);
|
||
const near = (a, b) => Math.abs(a - b) <= 1.5;
|
||
check(
|
||
'the pen drew a closed shape of three points where it was clicked',
|
||
Boolean(drawn) &&
|
||
drawn.rings.length === 1 &&
|
||
drawn.rings[0].closed &&
|
||
drawn.rings[0].points.length === 3 &&
|
||
drawn.rings[0].points.every((p, i) => near(p.x, TRIANGLE[i].x) && near(p.y, TRIANGLE[i].y)) &&
|
||
(await json('__gm.vectorArt.drawing()')) === false,
|
||
JSON.stringify(drawn?.rings?.[0]),
|
||
);
|
||
|
||
// 47d. The arrow: a point is dragged, one is made round, one is taken away.
|
||
await clickText('Select');
|
||
const corner = await json(`__gm.vectorArt.pointAt(${TRIANGLE[1].x}, ${TRIANGLE[1].y})`);
|
||
const target = await json(`__gm.vectorArt.pointAt(180, 40)`);
|
||
await drag(corner.x, corner.y, target.x, target.y);
|
||
const moved = (await json("__gm.vectorArt.shapes('va-art')")).at(-1).rings[0].points[1];
|
||
check(
|
||
'dragging a point writes it where the pointer left it',
|
||
near(moved.x, 180) && near(moved.y, 40),
|
||
JSON.stringify(moved),
|
||
);
|
||
|
||
// The magnet is the graph's (#194): the tab has no Grid button of its own any more, and with it
|
||
// on a ragged drop lands on the 8 px grid. Undone, so the point is back where the next steps expect it.
|
||
const magnet = await json('__gm.vectorArt.snapButton()');
|
||
check(
|
||
'the tab carries the magnet in place of a Grid button',
|
||
magnet.w === magnet.h && !magnet.toggled && (await json(`__gm.locate('Grid')`)) === null,
|
||
JSON.stringify(magnet),
|
||
);
|
||
await click(magnet.x, magnet.y);
|
||
const snapFrom = await json(`__gm.vectorArt.pointAt(180, 40)`);
|
||
const snapTo = await json(`__gm.vectorArt.pointAt(187, 45)`);
|
||
await drag(snapFrom.x, snapFrom.y, snapTo.x, snapTo.y);
|
||
const onGrid = (await json("__gm.vectorArt.shapes('va-art')")).at(-1).rings[0].points[1];
|
||
check(
|
||
'with the magnet on, a dragged point lands on the grid',
|
||
(await json('__gm.vectorArt.snapping()')) === true && onGrid.x === 184 && onGrid.y === 48,
|
||
JSON.stringify(onGrid),
|
||
);
|
||
await shot('47-magnet');
|
||
await undo();
|
||
await click(magnet.x, magnet.y);
|
||
check('the magnet switches snapping off again', (await json('__gm.vectorArt.snapping()')) === false);
|
||
|
||
const pulled = await json(`__gm.vectorArt.pointAt(180, 40)`);
|
||
await click(pulled.x, pulled.y, 'left', { alt: true });
|
||
const smooth = (await json("__gm.vectorArt.shapes('va-art')")).at(-1).rings[0].points[1];
|
||
check(
|
||
'Alt on a point makes it round – it grows the two handles of a curve',
|
||
Boolean(smooth.in) && Boolean(smooth.out),
|
||
JSON.stringify(smooth),
|
||
);
|
||
|
||
await shot('47-vector-tab');
|
||
|
||
const firstPoint = await json(`__gm.vectorArt.pointAt(${TRIANGLE[0].x}, ${TRIANGLE[0].y})`);
|
||
await doubleClick(firstPoint.x, firstPoint.y);
|
||
const left = (await json("__gm.vectorArt.shapes('va-art')")).at(-1).rings[0].points;
|
||
check(
|
||
'a double-click takes a point away',
|
||
left.length === 2 && near(left[0].x, 180),
|
||
JSON.stringify(left.map((p) => [Math.round(p.x), Math.round(p.y)])),
|
||
);
|
||
|
||
// The drawing is a picture like any other: what the tool changed reaches the sprite by itself.
|
||
const undrawn = await json("__gm.drawnPicture('va-art')");
|
||
check(
|
||
'what was drawn in the tab is the picture the sprite wears',
|
||
Boolean(undrawn) && undrawn.file !== after?.file && undrawn.kept,
|
||
JSON.stringify({ was: after?.file, now: undrawn?.file }),
|
||
);
|
||
|
||
// 47e. The inspector (stage 4): the shapes are listed, picked, coloured and reordered there.
|
||
const shapeIds = async () => (await json("__gm.vectorArt.shapes('va-art')")).map((s) => s.id);
|
||
const shapeOf = async (id) =>
|
||
(await json("__gm.vectorArt.shapes('va-art')")).find((shape) => shape.id === id);
|
||
const rows = await json('__gm.vectorArt.shapeRows()');
|
||
const model = await shapeIds();
|
||
check(
|
||
'the inspector lists the shapes, the one drawn last on top',
|
||
Array.isArray(rows) && rows.length === model.length && rows.join() === [...model].reverse().join(),
|
||
JSON.stringify({ rows, model }),
|
||
);
|
||
|
||
// A row is picked by where it sits: its name also stands in the tab, so text would be ambiguous.
|
||
const fieldRow = await json("__gm.vectorArt.shapeRowAt('va-field')");
|
||
await click(fieldRow.x + fieldRow.w / 2, fieldRow.y + fieldRow.h / 2);
|
||
await settle();
|
||
check(
|
||
'clicking a row selects that shape',
|
||
(await json('__gm.vectorArt.selected()')) === 'va-field',
|
||
JSON.stringify({ selected: await json('__gm.vectorArt.selected()'), row: fieldRow }),
|
||
);
|
||
|
||
// The control of a row, not an offset from its label: a wider label pushes its control right.
|
||
const fillField = await json("__gm.inspectorField('Fill')");
|
||
await click(fillField.x, fillField.y);
|
||
// The click puts a caret in the hex text rather than selecting it, so the old value goes first.
|
||
await press('a', { ctrl: true });
|
||
await typeText('20c0e8');
|
||
await press('Enter');
|
||
await settle();
|
||
const recoloured = await shapeOf('va-field');
|
||
// Read off the tab as well: a row that only writes the model would show nothing to the eye.
|
||
const spot = await json('__gm.vectorArt.pointAt(48, 200)');
|
||
const painted = await json(`__gm.pixelAt(${Math.round(spot.x)}, ${Math.round(spot.y)})`);
|
||
check(
|
||
'the Fill row recolours the shape, and the tab shows it',
|
||
recoloured?.fill === 0x20c0e8 && colourDistance(painted, '#20c0e8') < 14,
|
||
JSON.stringify({ fill: recoloured?.fill?.toString(16), painted }),
|
||
);
|
||
|
||
const outlineField = await json("__gm.inspectorField('Outline')");
|
||
await click(outlineField.x, outlineField.y);
|
||
await settle();
|
||
const outlined = await shapeOf('va-field');
|
||
check(
|
||
'the Outline switch gives the shape a line, and its rows appear with it',
|
||
Boolean(outlined?.stroke) && outlined.stroke.width === 2 && Boolean(await locate('Line width')),
|
||
JSON.stringify(outlined?.stroke),
|
||
);
|
||
|
||
// The badge was drawn over the field; "To front" puts the field over the badge instead.
|
||
const covered = await json('__gm.vectorArt.pointAt(204, 60)');
|
||
const wasBadge = await json(`__gm.pixelAt(${Math.round(covered.x)}, ${Math.round(covered.y)})`);
|
||
await clickText('To front');
|
||
await settle();
|
||
const nowField = await json(`__gm.pixelAt(${Math.round(covered.x)}, ${Math.round(covered.y)})`);
|
||
check(
|
||
'To front draws the shape over the one that covered it',
|
||
(await shapeIds()).at(-1) === 'va-field' &&
|
||
colourDistance(wasBadge, BADGE) < 14 &&
|
||
colourDistance(nowField, '#20c0e8') < 14,
|
||
JSON.stringify({ order: await shapeIds(), wasBadge, nowField }),
|
||
);
|
||
|
||
await shot('47-shape-rows');
|
||
|
||
// Dragging the top row to the bottom of the list is the same move the other way round.
|
||
const top = await json("__gm.vectorArt.shapeRowAt('va-field')");
|
||
await drag(
|
||
top.x + top.w / 2,
|
||
top.y + top.h / 2,
|
||
top.x + top.w / 2,
|
||
top.y + top.h * (await shapeIds()).length,
|
||
);
|
||
await settle();
|
||
check(
|
||
'dragging a row to the bottom draws that shape first',
|
||
(await shapeIds())[0] === 'va-field',
|
||
JSON.stringify(await shapeIds()),
|
||
);
|
||
|
||
const before47e = (await shapeIds()).length;
|
||
await clickText('Add shape');
|
||
await settle();
|
||
const added = await shapeIds();
|
||
check(
|
||
'Add shape puts an empty shape on top and selects it',
|
||
added.length === before47e + 1 && (await json('__gm.vectorArt.selected()')) === added.at(-1),
|
||
JSON.stringify({ added: added.length, selected: await json('__gm.vectorArt.selected()') }),
|
||
);
|
||
|
||
// 47f (stage 5). The way a user takes, which the blocks before took by hand: the asset is made
|
||
// from the + menu, its row opens the tab, and a sprite wears the drawing through its
|
||
// Texture row – the picture picker of #185, which until here only unit tests had seen.
|
||
// "Vector art" is the menu entry *and* the label of the open tab, so the entry is picked
|
||
// inside the menu's own box rather than by text alone.
|
||
const inOverlay = async (text) => {
|
||
const boxes = await json('__gm.overlayRect()');
|
||
const hits = (await json(`__gm.locateAll(${JSON.stringify(text)})`)) ?? [];
|
||
return (
|
||
hits.find(
|
||
(hit) =>
|
||
hit.x >= boxes.x && hit.x <= boxes.x + boxes.w && hit.y >= boxes.y && hit.y <= boxes.y + boxes.h,
|
||
) ?? null
|
||
);
|
||
};
|
||
|
||
await clickText('+');
|
||
const plusRows = (await json('__gm.menuRows()')) ?? [];
|
||
const entry = await inOverlay('Vector art');
|
||
check(
|
||
'the + menu offers Vector art among the types that only live in the library',
|
||
plusRows.includes('Vector art') && Boolean(entry),
|
||
JSON.stringify({ offered: plusRows.includes('Vector art'), entry }),
|
||
);
|
||
await click(entry.x, entry.y);
|
||
await settle();
|
||
const madeId = (
|
||
await json("__gm.state.project.assets.filter((a) => a.type === 'vectorart').map((a) => a.id)")
|
||
).at(-1);
|
||
const madeRow = await json(`__gm.assetRow(${JSON.stringify(madeId)})`);
|
||
check(
|
||
'the entry makes a vector art asset, selected and standing in the panel',
|
||
madeId !== 'va-art' && (await json('__gm.state.selection?.assetId')) === madeId && Boolean(madeRow),
|
||
JSON.stringify({ madeId, row: madeRow }),
|
||
);
|
||
|
||
// Its row opens the tab on that asset – until here the tab was opened by `openDocument`.
|
||
await showTab('scene');
|
||
await doubleClick(madeRow.x, madeRow.y);
|
||
await settle();
|
||
check(
|
||
'a double-click on the row opens the Vector art tab on that asset',
|
||
(await json('__gm.vectorArt.current()')) === madeId,
|
||
JSON.stringify({ current: await json('__gm.vectorArt.current()'), wanted: madeId }),
|
||
);
|
||
|
||
// A sprite that wears nothing yet: the drawing has to be reachable from its Texture row.
|
||
const Y2 = Y - 400;
|
||
await mutate(`
|
||
const project = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
${MAKE_OBJECT}
|
||
const bare = structuredClone(project.assets.find((a) => a.id === 'va-sprite'));
|
||
bare.id = 'va-bare';
|
||
bare.name = 'Bare sprite';
|
||
bare.props = { ...bare.props, texture: '' };
|
||
project.assets.push(bare);
|
||
scene.objects.push(make('va-bare-object', 'va-bare', '', ${Y2}, 'Bare object'));
|
||
__gm.state.select({ kind: 'asset', assetId: 'va-bare' })`);
|
||
await showTab('scene');
|
||
await evaluate(`__gm.lookAt(0, ${Y2})`);
|
||
await settle();
|
||
|
||
const textureRow = await json("__gm.inspectorField('Texture', true)");
|
||
await click(textureRow.x, textureRow.y);
|
||
await settle();
|
||
const offers = (await json('__gm.menuRows()')) ?? [];
|
||
// Measured against the model rather than against a fixed list: run alone the fixture holds no
|
||
// uploaded picture at all, while after module 12 it holds several. What has to hold either way
|
||
// is that every picture asset is offered by name, the drawings among them and sorted with them.
|
||
const pictures = await json(
|
||
"__gm.state.project.assets.filter((a) => a.type === 'texture' || a.type === 'vectorart')" +
|
||
'.map((a) => a.name).sort((a, b) => a.localeCompare(b))',
|
||
);
|
||
const offeredAssets = offers.filter((row) => pictures.includes(row));
|
||
check(
|
||
'the Texture row offers the drawing by name, sorted among the other pictures',
|
||
offers.includes('Emblem') &&
|
||
offeredAssets.join() === pictures.join() &&
|
||
// The row it wears right now is marked, so "None" arrives with a bullet in front of it.
|
||
offers.some((row) => typeof row === 'string' && row.endsWith('None')),
|
||
JSON.stringify({ offers, pictures }),
|
||
);
|
||
|
||
const drawingEntry = await inOverlay('Emblem');
|
||
await click(drawingEntry.x, drawingEntry.y);
|
||
await settle();
|
||
await sleep(300);
|
||
const bareBox = await json("__gm.objectRect('va-bare-object')");
|
||
const wornAt = (cx, cy) =>
|
||
json(
|
||
`__gm.pixelAt(${Math.round(bareBox.x + (cx / CANVAS - 0.5) * bareBox.w)}, ${Math.round(bareBox.y + (cy / CANVAS - 0.5) * bareBox.h)})`,
|
||
);
|
||
// The field was recoloured in 47e and sent to the back, so this is the colour of that row.
|
||
const wornPixel = await wornAt(48, 200);
|
||
check(
|
||
'picking it there makes the sprite wear the drawing, as the scene shows',
|
||
(await json("__gm.state.project.assets.find((a) => a.id === 'va-bare').props.texture")) === 'va-art' &&
|
||
colourDistance(wornPixel, '#20c0e8') < 14,
|
||
JSON.stringify({
|
||
worn: await json("__gm.state.project.assets.find((a) => a.id === 'va-bare').props.texture"),
|
||
wornPixel,
|
||
}),
|
||
);
|
||
await shot('47-picture-picker');
|
||
|
||
// 47g (#195). The pen draws a curve with two handles, and the header's point type steers what
|
||
// a dragged handle does to the other one: a corner leaves it, a smooth point turns it round
|
||
// at its own length, straight takes both away. Drawn on the asset 47f made, which is empty.
|
||
await evaluate(`__gm.openDocument('asset:${madeId}')`);
|
||
await settle();
|
||
const pointsOf = async () =>
|
||
(await json(`__gm.vectorArt.shapes(${JSON.stringify(madeId)})`)).at(-1)?.rings[0]?.points ?? [];
|
||
const screenAt = (x, y) => json(`__gm.vectorArt.pointAt(${x}, ${y})`);
|
||
const pointType = () => json('__gm.vectorArt.pointButton()');
|
||
const before195 = await pointType();
|
||
check(
|
||
'with no point picked the point type reads as none and cannot be opened',
|
||
before195.text === 'Point: –' && !before195.enabled,
|
||
JSON.stringify(before195),
|
||
);
|
||
|
||
await clickText('Pen');
|
||
const A = await screenAt(60, 160);
|
||
const B = await screenAt(128, 80);
|
||
const pulledTo = await screenAt(168, 80);
|
||
const C = await screenAt(196, 160);
|
||
await click(A.x, A.y);
|
||
await drag(B.x, B.y, pulledTo.x, pulledTo.y);
|
||
await click(C.x, C.y);
|
||
await click(A.x, A.y);
|
||
const penPoints = await pointsOf();
|
||
const curved = penPoints[1];
|
||
check(
|
||
'a drag with the pen grows both handles of the point, mirrored – a symmetric point',
|
||
penPoints.length === 3 &&
|
||
curved?.mode === 'symmetric' &&
|
||
near(curved.out?.x, 40) &&
|
||
near(curved.out?.y, 0) &&
|
||
near(curved.in?.x, -40) &&
|
||
near(curved.in?.y, 0),
|
||
JSON.stringify(penPoints),
|
||
);
|
||
|
||
await clickText('Select');
|
||
await click(B.x, B.y);
|
||
const picked = await pointType();
|
||
check(
|
||
'a click on that point picks it, and the header names its type',
|
||
picked.text === 'Point: Symmetric' && picked.enabled && picked.kind === 'symmetric',
|
||
JSON.stringify(picked),
|
||
);
|
||
|
||
// The menu ticks its entries in the text itself, so a row reads "☐ Corner" or "☑ Corner".
|
||
const openPointMenu = async () => {
|
||
const button = await pointType();
|
||
await click(button.x, button.y);
|
||
return (await json('__gm.menuRows()')) ?? [];
|
||
};
|
||
const choose = async (label) => {
|
||
const rows = await openPointMenu();
|
||
const text = rows.find((row) => row.endsWith(` ${label}`));
|
||
const row = text && (await inOverlay(text));
|
||
if (!row) throw new Error(`no "${label}" in the point type menu: ${JSON.stringify(rows)}`);
|
||
await click(row.x, row.y);
|
||
await settle();
|
||
};
|
||
|
||
const offered = await openPointMenu();
|
||
check(
|
||
'the point type menu offers the four types and ticks the one the point is',
|
||
offered.join('|') === '☐ Straight|☐ Corner|☐ Smooth|☑ Symmetric',
|
||
JSON.stringify(offered),
|
||
);
|
||
await press('Escape');
|
||
await settle();
|
||
const dragOut = async (x, y) => {
|
||
const point = (await pointsOf())[1];
|
||
const from = await screenAt(point.x + point.out.x, point.y + point.out.y);
|
||
const to = await screenAt(x, y);
|
||
await drag(from.x, from.y, to.x, to.y);
|
||
return (await pointsOf())[1];
|
||
};
|
||
|
||
await choose('Corner');
|
||
const cusp = await dragOut(128, 40);
|
||
check(
|
||
'on a corner a dragged handle leaves the other one where it was',
|
||
cusp.mode === null &&
|
||
near(cusp.out.x, 0) &&
|
||
near(cusp.out.y, -40) &&
|
||
near(cusp.in.x, -40) &&
|
||
near(cusp.in.y, 0),
|
||
JSON.stringify(cusp),
|
||
);
|
||
|
||
await choose('Smooth');
|
||
const lined = (await pointsOf())[1];
|
||
const smoothDragged = await dragOut(128 + 60, 80);
|
||
const inLength = Math.hypot(smoothDragged.in.x, smoothDragged.in.y);
|
||
check(
|
||
'Smooth lines the handles up, and dragging one turns the other round at its own length',
|
||
lined.mode === 'smooth' &&
|
||
smoothDragged.mode === 'smooth' &&
|
||
near(smoothDragged.out.x, 60) &&
|
||
near(smoothDragged.out.y, 0) &&
|
||
smoothDragged.in.x < 0 &&
|
||
near(smoothDragged.in.y, 0) &&
|
||
near(inLength, Math.hypot(lined.in.x, lined.in.y)) &&
|
||
!near(inLength, 60),
|
||
JSON.stringify({ lined, smoothDragged }),
|
||
);
|
||
await shot('47-point-smooth');
|
||
|
||
await choose('Straight');
|
||
const straight = (await pointsOf())[1];
|
||
check(
|
||
'Straight takes both handles away, and the header says so',
|
||
straight.in === null && straight.out === null && (await pointType()).text === 'Point: Straight',
|
||
JSON.stringify({ straight, header: await pointType() }),
|
||
);
|
||
}
|