Building a shape out of simpler ones meant drawing the result point by point. The Vector Art tab now selects several shapes at once and combines them with a boolean operation into one ordinary shape. - Ctrl/Cmd+click in the tab and Ctrl or Shift+click in the shape list add a shape to the selection. Shift stays pan in the canvas. - A COMBINE block in the inspector offers Union, Subtract, Intersect and Exclude. Subtract removes every upper shape from the lowest one. - The result replaces the lowest selected shape and keeps its name, colours, stroke and place in the order. It is one undo step. - Curves are flattened to 0.2 canvas pixels, a circle gets about 48 points. Open lines and single shapes cannot be combined, the buttons are off and a hint gives the reason. An empty result is rejected. - Delete now removes every selected shape, and the selection drops shapes that no longer exist. - The geometry comes from clipper2-ts 2.0.1, pinned. It has no dependencies and speaks even-odd directly. Only the engine module is imported, the bundle grows by 55 kB instead of 110 kB. Findings: - clipper2-js was tried and dropped. It pulls in Angular 15 with a high npm audit finding and no fix. - Pixi finds holes by containment, not by even-odd depth. An island inside a hole would be drawn as a hole again. Result rings are emitted outer ring before its islands, and a test mirrors Pixi's rule. - A depth sort turned out to be dead code in a counter-check and was removed. A pixel check in 47h stayed green without the subtract and now checks both colours. Tests: - ./check.sh: 134 files, 1986 tests, exit 0 (before 133 / 1968) - npm run build: exit 0 - GM_STEPS=47, freshly seeded: ALL PASSED, 44 checks (before 39) - 18 new unit tests, 5 new e2e checks in block 47h - Five counter-checks bite, originals restored and verified with cmp - Check sum 1222 in README and wiki, calculated (1217 + 5) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
686 lines
27 KiB
JavaScript
686 lines
27 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, and 47h combines two shapes on it (#187).
|
||
*/
|
||
|
||
const CANVAS = 256;
|
||
const FIELD = '#2e8b57';
|
||
const BADGE = '#dd4433';
|
||
const OUTLINE = '#1133ee';
|
||
const REDRAWN = '#e8c020';
|
||
|
||
/** The square field with a square hole in it, and a badge with an outline drawn over it. */
|
||
const SHAPES = [
|
||
{
|
||
id: 'va-field',
|
||
name: 'Field',
|
||
fill: 0x2e8b57,
|
||
opacity: 1,
|
||
rings: [
|
||
{
|
||
closed: true,
|
||
points: [
|
||
{ x: 16, y: 16 },
|
||
{ x: 240, y: 16 },
|
||
{ x: 240, y: 240 },
|
||
{ x: 16, y: 240 },
|
||
],
|
||
},
|
||
{
|
||
closed: true,
|
||
points: [
|
||
{ x: 96, y: 96 },
|
||
{ x: 160, y: 96 },
|
||
{ x: 160, y: 160 },
|
||
{ x: 96, y: 160 },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: 'va-badge',
|
||
name: 'Badge',
|
||
fill: 0xdd4433,
|
||
opacity: 1,
|
||
stroke: { color: 0x1133ee, width: 10 },
|
||
rings: [
|
||
{
|
||
closed: true,
|
||
points: [
|
||
{ x: 176, y: 32 },
|
||
{ x: 232, y: 32 },
|
||
{ x: 232, y: 88 },
|
||
{ x: 176, y: 88 },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
];
|
||
|
||
export async function vectorArt(t) {
|
||
const {
|
||
check,
|
||
click,
|
||
doubleClick,
|
||
drag,
|
||
clickText,
|
||
json,
|
||
evaluate,
|
||
locate,
|
||
mutate,
|
||
press,
|
||
settle,
|
||
sleep,
|
||
shot,
|
||
showTab,
|
||
typeText,
|
||
undo,
|
||
} = t;
|
||
const Y = LANES.vectorArt;
|
||
|
||
// 47a. A drawing on a sprite: what the rasteriser put on the GPU, read off the screen.
|
||
await mutate(`
|
||
const project = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
${MAKE_OBJECT}
|
||
project.assets.push({ id: 'va-art', type: 'vectorart', name: 'Emblem',
|
||
props: { canvasWidth: ${CANVAS}, canvasHeight: ${CANVAS} }, shapes: ${JSON.stringify(SHAPES)} });
|
||
const sprite = structuredClone(project.assets.find((a) => a.id === 'crate'));
|
||
sprite.id = 'va-sprite';
|
||
sprite.name = 'Emblem sprite';
|
||
sprite.props = { ...sprite.props, width: ${CANVAS}, height: ${CANVAS}, color: 0xffffff,
|
||
texture: 'va-art', shader: '' };
|
||
project.assets.push(sprite);
|
||
scene.objects.push(make('va-object', 'va-sprite', '', ${Y}, 'Emblem object'));
|
||
__gm.state.select(null)`);
|
||
await showTab('scene');
|
||
await evaluate(`__gm.lookAt(0, ${Y})`);
|
||
await settle();
|
||
await sleep(300);
|
||
|
||
const picture = await json("__gm.drawnPicture('va-art')");
|
||
check(
|
||
'the drawing lies on the GPU at twice its canvas, and measures the canvas',
|
||
Boolean(picture) &&
|
||
picture.width === CANVAS &&
|
||
picture.height === CANVAS &&
|
||
picture.pixelWidth === CANVAS * 2 &&
|
||
picture.pixelHeight === CANVAS * 2 &&
|
||
picture.kept,
|
||
JSON.stringify(picture),
|
||
);
|
||
|
||
const box = await json("__gm.objectRect('va-object')");
|
||
check('the sprite wearing the drawing is on screen', Boolean(box) && box.w > 32, JSON.stringify(box));
|
||
if (!box) return;
|
||
/**
|
||
* A point of the canvas, in canvas pixels, as the colour on screen. `objectRect` answers with the
|
||
* middle of the object, not its corner, so the canvas is measured out from there.
|
||
*/
|
||
const at = (cx, cy) =>
|
||
json(
|
||
`__gm.pixelAt(${Math.round(box.x + (cx / CANVAS - 0.5) * box.w)}, ${Math.round(box.y + (cy / CANVAS - 0.5) * box.h)})`,
|
||
);
|
||
|
||
const field = await at(48, 200);
|
||
check('the sprite shows the fill of the first shape', colourDistance(field, FIELD) < 14, field);
|
||
|
||
const outside = await json(`__gm.pixelAt(${Math.round(box.x - box.w)}, ${Math.round(box.y)})`);
|
||
// Off the middle of the hole: the scene's grid lines cross exactly there, and a grid line is not
|
||
// the background either – the check would then read the wrong colour for the right reason.
|
||
const hole = await at(112, 148);
|
||
check(
|
||
'the ring inside the shape is a hole – the scene shows through it',
|
||
colourDistance(hole, outside) < 14 && colourDistance(hole, FIELD) > 40,
|
||
JSON.stringify({ hole, outside }),
|
||
);
|
||
|
||
const badge = await at(204, 60);
|
||
check('the shape drawn later lies over the one before it', colourDistance(badge, BADGE) < 14, badge);
|
||
|
||
const outline = await at(176, 60);
|
||
check('the outline is drawn along the ring', colourDistance(outline, OUTLINE) < 24, outline);
|
||
|
||
await shot('47-vector-art');
|
||
|
||
// 47b. A changed drawing is a new picture (#185): the key moves on, the old one is let go.
|
||
const before = picture?.file;
|
||
await mutate(`
|
||
const art = __gm.state.project.assets.find((a) => a.id === 'va-art');
|
||
art.shapes[0].fill = 0x${REDRAWN.slice(1)}`);
|
||
await settle();
|
||
await sleep(300);
|
||
const after = await json("__gm.drawnPicture('va-art')");
|
||
const oldOne = before ? await json(`__gm.textureFilter(${JSON.stringify(before)})`) : null;
|
||
check(
|
||
'a changed drawing gets a key of its own and the old picture is freed',
|
||
Boolean(after) && after.file !== before && after.kept && oldOne === null,
|
||
JSON.stringify({ before, after: after?.file, oldOne }),
|
||
);
|
||
const repainted = await at(48, 200);
|
||
check('the sprite shows the changed fill', colourDistance(repainted, REDRAWN) < 14, repainted);
|
||
|
||
// 47c. The tab: the pen draws a triangle and closes it.
|
||
await evaluate("__gm.openDocument('asset:va-art')");
|
||
await settle();
|
||
const info = await json('__gm.vectorArt.info()');
|
||
check(
|
||
'the Vector art tab shows the drawing and its canvas',
|
||
(await json('__gm.vectorArt.current()')) === 'va-art' && info.startsWith(`${CANVAS} \u00d7 ${CANVAS} px`),
|
||
JSON.stringify({ current: await json('__gm.vectorArt.current()'), info }),
|
||
);
|
||
|
||
await clickText('Pen');
|
||
check('the pen is the tool a press uses', (await json('__gm.vectorArt.tool()')) === 'pen');
|
||
|
||
const TRIANGLE = [
|
||
{ x: 40, y: 40 },
|
||
{ x: 110, y: 40 },
|
||
{ x: 110, y: 110 },
|
||
];
|
||
const screen = [];
|
||
for (const point of TRIANGLE) {
|
||
const at = await json(`__gm.vectorArt.pointAt(${point.x}, ${point.y})`);
|
||
screen.push(at);
|
||
await click(at.x, at.y);
|
||
}
|
||
// The first point again: that is what closes a line, and the pen has nothing open afterwards.
|
||
await click(screen[0].x, screen[0].y);
|
||
await settle();
|
||
|
||
const drawn = (await json("__gm.vectorArt.shapes('va-art')")).at(-1);
|
||
const near = (a, b) => Math.abs(a - b) <= 1.5;
|
||
check(
|
||
'the pen drew a closed shape of three points where it was clicked',
|
||
Boolean(drawn) &&
|
||
drawn.rings.length === 1 &&
|
||
drawn.rings[0].closed &&
|
||
drawn.rings[0].points.length === 3 &&
|
||
drawn.rings[0].points.every((p, i) => near(p.x, TRIANGLE[i].x) && near(p.y, TRIANGLE[i].y)) &&
|
||
(await json('__gm.vectorArt.drawing()')) === false,
|
||
JSON.stringify(drawn?.rings?.[0]),
|
||
);
|
||
|
||
// 47d. The arrow: a point is dragged, one is made round, one is taken away.
|
||
await clickText('Select');
|
||
const corner = await json(`__gm.vectorArt.pointAt(${TRIANGLE[1].x}, ${TRIANGLE[1].y})`);
|
||
const target = await json(`__gm.vectorArt.pointAt(180, 40)`);
|
||
await drag(corner.x, corner.y, target.x, target.y);
|
||
const moved = (await json("__gm.vectorArt.shapes('va-art')")).at(-1).rings[0].points[1];
|
||
check(
|
||
'dragging a point writes it where the pointer left it',
|
||
near(moved.x, 180) && near(moved.y, 40),
|
||
JSON.stringify(moved),
|
||
);
|
||
|
||
// The magnet is the graph's (#194): the tab has no Grid button of its own any more, and with it
|
||
// on a ragged drop lands on the 8 px grid. Undone, so the point is back where the next steps expect it.
|
||
const magnet = await json('__gm.vectorArt.snapButton()');
|
||
check(
|
||
'the tab carries the magnet in place of a Grid button',
|
||
magnet.w === magnet.h && !magnet.toggled && (await json(`__gm.locate('Grid')`)) === null,
|
||
JSON.stringify(magnet),
|
||
);
|
||
await click(magnet.x, magnet.y);
|
||
const snapFrom = await json(`__gm.vectorArt.pointAt(180, 40)`);
|
||
const snapTo = await json(`__gm.vectorArt.pointAt(187, 45)`);
|
||
await drag(snapFrom.x, snapFrom.y, snapTo.x, snapTo.y);
|
||
const onGrid = (await json("__gm.vectorArt.shapes('va-art')")).at(-1).rings[0].points[1];
|
||
check(
|
||
'with the magnet on, a dragged point lands on the grid',
|
||
(await json('__gm.vectorArt.snapping()')) === true && onGrid.x === 184 && onGrid.y === 48,
|
||
JSON.stringify(onGrid),
|
||
);
|
||
await shot('47-magnet');
|
||
await undo();
|
||
await click(magnet.x, magnet.y);
|
||
check('the magnet switches snapping off again', (await json('__gm.vectorArt.snapping()')) === false);
|
||
|
||
const pulled = await json(`__gm.vectorArt.pointAt(180, 40)`);
|
||
await click(pulled.x, pulled.y, 'left', { alt: true });
|
||
const smooth = (await json("__gm.vectorArt.shapes('va-art')")).at(-1).rings[0].points[1];
|
||
check(
|
||
'Alt on a point makes it round – it grows the two handles of a curve',
|
||
Boolean(smooth.in) && Boolean(smooth.out),
|
||
JSON.stringify(smooth),
|
||
);
|
||
|
||
await shot('47-vector-tab');
|
||
|
||
const firstPoint = await json(`__gm.vectorArt.pointAt(${TRIANGLE[0].x}, ${TRIANGLE[0].y})`);
|
||
await doubleClick(firstPoint.x, firstPoint.y);
|
||
const left = (await json("__gm.vectorArt.shapes('va-art')")).at(-1).rings[0].points;
|
||
check(
|
||
'a double-click takes a point away',
|
||
left.length === 2 && near(left[0].x, 180),
|
||
JSON.stringify(left.map((p) => [Math.round(p.x), Math.round(p.y)])),
|
||
);
|
||
|
||
// The drawing is a picture like any other: what the tool changed reaches the sprite by itself.
|
||
const undrawn = await json("__gm.drawnPicture('va-art')");
|
||
check(
|
||
'what was drawn in the tab is the picture the sprite wears',
|
||
Boolean(undrawn) && undrawn.file !== after?.file && undrawn.kept,
|
||
JSON.stringify({ was: after?.file, now: undrawn?.file }),
|
||
);
|
||
|
||
// 47e. The inspector (stage 4): the shapes are listed, picked, coloured and reordered there.
|
||
const shapeIds = async () => (await json("__gm.vectorArt.shapes('va-art')")).map((s) => s.id);
|
||
const shapeOf = async (id) =>
|
||
(await json("__gm.vectorArt.shapes('va-art')")).find((shape) => shape.id === id);
|
||
const rows = await json('__gm.vectorArt.shapeRows()');
|
||
const model = await shapeIds();
|
||
check(
|
||
'the inspector lists the shapes, the one drawn last on top',
|
||
Array.isArray(rows) && rows.length === model.length && rows.join() === [...model].reverse().join(),
|
||
JSON.stringify({ rows, model }),
|
||
);
|
||
|
||
// A row is picked by where it sits: its name also stands in the tab, so text would be ambiguous.
|
||
const fieldRow = await json("__gm.vectorArt.shapeRowAt('va-field')");
|
||
await click(fieldRow.x + fieldRow.w / 2, fieldRow.y + fieldRow.h / 2);
|
||
await settle();
|
||
check(
|
||
'clicking a row selects that shape',
|
||
(await json('__gm.vectorArt.selected()')) === 'va-field',
|
||
JSON.stringify({ selected: await json('__gm.vectorArt.selected()'), row: fieldRow }),
|
||
);
|
||
|
||
// The control of a row, not an offset from its label: a wider label pushes its control right.
|
||
const fillField = await json("__gm.inspectorField('Fill')");
|
||
await click(fillField.x, fillField.y);
|
||
// The click puts a caret in the hex text rather than selecting it, so the old value goes first.
|
||
await press('a', { ctrl: true });
|
||
await typeText('20c0e8');
|
||
await press('Enter');
|
||
await settle();
|
||
const recoloured = await shapeOf('va-field');
|
||
// Read off the tab as well: a row that only writes the model would show nothing to the eye.
|
||
const spot = await json('__gm.vectorArt.pointAt(48, 200)');
|
||
const painted = await json(`__gm.pixelAt(${Math.round(spot.x)}, ${Math.round(spot.y)})`);
|
||
check(
|
||
'the Fill row recolours the shape, and the tab shows it',
|
||
recoloured?.fill === 0x20c0e8 && colourDistance(painted, '#20c0e8') < 14,
|
||
JSON.stringify({ fill: recoloured?.fill?.toString(16), painted }),
|
||
);
|
||
|
||
const outlineField = await json("__gm.inspectorField('Outline')");
|
||
await click(outlineField.x, outlineField.y);
|
||
await settle();
|
||
const outlined = await shapeOf('va-field');
|
||
check(
|
||
'the Outline switch gives the shape a line, and its rows appear with it',
|
||
Boolean(outlined?.stroke) && outlined.stroke.width === 2 && Boolean(await locate('Line width')),
|
||
JSON.stringify(outlined?.stroke),
|
||
);
|
||
|
||
// The badge was drawn over the field; "To front" puts the field over the badge instead.
|
||
const covered = await json('__gm.vectorArt.pointAt(204, 60)');
|
||
const wasBadge = await json(`__gm.pixelAt(${Math.round(covered.x)}, ${Math.round(covered.y)})`);
|
||
await clickText('To front');
|
||
await settle();
|
||
const nowField = await json(`__gm.pixelAt(${Math.round(covered.x)}, ${Math.round(covered.y)})`);
|
||
check(
|
||
'To front draws the shape over the one that covered it',
|
||
(await shapeIds()).at(-1) === 'va-field' &&
|
||
colourDistance(wasBadge, BADGE) < 14 &&
|
||
colourDistance(nowField, '#20c0e8') < 14,
|
||
JSON.stringify({ order: await shapeIds(), wasBadge, nowField }),
|
||
);
|
||
|
||
await shot('47-shape-rows');
|
||
|
||
// Dragging the top row to the bottom of the list is the same move the other way round.
|
||
const top = await json("__gm.vectorArt.shapeRowAt('va-field')");
|
||
await drag(
|
||
top.x + top.w / 2,
|
||
top.y + top.h / 2,
|
||
top.x + top.w / 2,
|
||
top.y + top.h * (await shapeIds()).length,
|
||
);
|
||
await settle();
|
||
check(
|
||
'dragging a row to the bottom draws that shape first',
|
||
(await shapeIds())[0] === 'va-field',
|
||
JSON.stringify(await shapeIds()),
|
||
);
|
||
|
||
const before47e = (await shapeIds()).length;
|
||
await clickText('Add shape');
|
||
await settle();
|
||
const added = await shapeIds();
|
||
check(
|
||
'Add shape puts an empty shape on top and selects it',
|
||
added.length === before47e + 1 && (await json('__gm.vectorArt.selected()')) === added.at(-1),
|
||
JSON.stringify({ added: added.length, selected: await json('__gm.vectorArt.selected()') }),
|
||
);
|
||
|
||
// 47f (stage 5). The way a user takes, which the blocks before took by hand: the asset is made
|
||
// from the + menu, its row opens the tab, and a sprite wears the drawing through its
|
||
// Texture row – the picture picker of #185, which until here only unit tests had seen.
|
||
// "Vector art" is the menu entry *and* the label of the open tab, so the entry is picked
|
||
// inside the menu's own box rather than by text alone.
|
||
const inOverlay = async (text) => {
|
||
const boxes = await json('__gm.overlayRect()');
|
||
const hits = (await json(`__gm.locateAll(${JSON.stringify(text)})`)) ?? [];
|
||
return (
|
||
hits.find(
|
||
(hit) =>
|
||
hit.x >= boxes.x && hit.x <= boxes.x + boxes.w && hit.y >= boxes.y && hit.y <= boxes.y + boxes.h,
|
||
) ?? null
|
||
);
|
||
};
|
||
|
||
await clickText('+');
|
||
const plusRows = (await json('__gm.menuRows()')) ?? [];
|
||
const entry = await inOverlay('Vector art');
|
||
check(
|
||
'the + menu offers Vector art among the types that only live in the library',
|
||
plusRows.includes('Vector art') && Boolean(entry),
|
||
JSON.stringify({ offered: plusRows.includes('Vector art'), entry }),
|
||
);
|
||
await click(entry.x, entry.y);
|
||
await settle();
|
||
const madeId = (
|
||
await json("__gm.state.project.assets.filter((a) => a.type === 'vectorart').map((a) => a.id)")
|
||
).at(-1);
|
||
const madeRow = await json(`__gm.assetRow(${JSON.stringify(madeId)})`);
|
||
check(
|
||
'the entry makes a vector art asset, selected and standing in the panel',
|
||
madeId !== 'va-art' && (await json('__gm.state.selection?.assetId')) === madeId && Boolean(madeRow),
|
||
JSON.stringify({ madeId, row: madeRow }),
|
||
);
|
||
|
||
// Its row opens the tab on that asset – until here the tab was opened by `openDocument`.
|
||
await showTab('scene');
|
||
await doubleClick(madeRow.x, madeRow.y);
|
||
await settle();
|
||
check(
|
||
'a double-click on the row opens the Vector art tab on that asset',
|
||
(await json('__gm.vectorArt.current()')) === madeId,
|
||
JSON.stringify({ current: await json('__gm.vectorArt.current()'), wanted: madeId }),
|
||
);
|
||
|
||
// A sprite that wears nothing yet: the drawing has to be reachable from its Texture row.
|
||
const Y2 = Y - 400;
|
||
await mutate(`
|
||
const project = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
${MAKE_OBJECT}
|
||
const bare = structuredClone(project.assets.find((a) => a.id === 'va-sprite'));
|
||
bare.id = 'va-bare';
|
||
bare.name = 'Bare sprite';
|
||
bare.props = { ...bare.props, texture: '' };
|
||
project.assets.push(bare);
|
||
scene.objects.push(make('va-bare-object', 'va-bare', '', ${Y2}, 'Bare object'));
|
||
__gm.state.select({ kind: 'asset', assetId: 'va-bare' })`);
|
||
await showTab('scene');
|
||
await evaluate(`__gm.lookAt(0, ${Y2})`);
|
||
await settle();
|
||
|
||
const textureRow = await json("__gm.inspectorField('Texture', true)");
|
||
await click(textureRow.x, textureRow.y);
|
||
await settle();
|
||
const offers = (await json('__gm.menuRows()')) ?? [];
|
||
// Measured against the model rather than against a fixed list: run alone the fixture holds no
|
||
// uploaded picture at all, while after module 12 it holds several. What has to hold either way
|
||
// is that every picture asset is offered by name, the drawings among them and sorted with them.
|
||
const pictures = await json(
|
||
"__gm.state.project.assets.filter((a) => a.type === 'texture' || a.type === 'vectorart')" +
|
||
'.map((a) => a.name).sort((a, b) => a.localeCompare(b))',
|
||
);
|
||
const offeredAssets = offers.filter((row) => pictures.includes(row));
|
||
check(
|
||
'the Texture row offers the drawing by name, sorted among the other pictures',
|
||
offers.includes('Emblem') &&
|
||
offeredAssets.join() === pictures.join() &&
|
||
// The row it wears right now is marked, so "None" arrives with a bullet in front of it.
|
||
offers.some((row) => typeof row === 'string' && row.endsWith('None')),
|
||
JSON.stringify({ offers, pictures }),
|
||
);
|
||
|
||
const drawingEntry = await inOverlay('Emblem');
|
||
await click(drawingEntry.x, drawingEntry.y);
|
||
await settle();
|
||
await sleep(300);
|
||
const bareBox = await json("__gm.objectRect('va-bare-object')");
|
||
const wornAt = (cx, cy) =>
|
||
json(
|
||
`__gm.pixelAt(${Math.round(bareBox.x + (cx / CANVAS - 0.5) * bareBox.w)}, ${Math.round(bareBox.y + (cy / CANVAS - 0.5) * bareBox.h)})`,
|
||
);
|
||
// The field was recoloured in 47e and sent to the back, so this is the colour of that row.
|
||
const wornPixel = await wornAt(48, 200);
|
||
check(
|
||
'picking it there makes the sprite wear the drawing, as the scene shows',
|
||
(await json("__gm.state.project.assets.find((a) => a.id === 'va-bare').props.texture")) === 'va-art' &&
|
||
colourDistance(wornPixel, '#20c0e8') < 14,
|
||
JSON.stringify({
|
||
worn: await json("__gm.state.project.assets.find((a) => a.id === 'va-bare').props.texture"),
|
||
wornPixel,
|
||
}),
|
||
);
|
||
await shot('47-picture-picker');
|
||
|
||
// 47g (#195). The pen draws a curve with two handles, and the header's point type steers what
|
||
// a dragged handle does to the other one: a corner leaves it, a smooth point turns it round
|
||
// at its own length, straight takes both away. Drawn on the asset 47f made, which is empty.
|
||
await evaluate(`__gm.openDocument('asset:${madeId}')`);
|
||
await settle();
|
||
const pointsOf = async () =>
|
||
(await json(`__gm.vectorArt.shapes(${JSON.stringify(madeId)})`)).at(-1)?.rings[0]?.points ?? [];
|
||
const screenAt = (x, y) => json(`__gm.vectorArt.pointAt(${x}, ${y})`);
|
||
const pointType = () => json('__gm.vectorArt.pointButton()');
|
||
const before195 = await pointType();
|
||
check(
|
||
'with no point picked the point type reads as none and cannot be opened',
|
||
before195.text === 'Point: –' && !before195.enabled,
|
||
JSON.stringify(before195),
|
||
);
|
||
|
||
await clickText('Pen');
|
||
const A = await screenAt(60, 160);
|
||
const B = await screenAt(128, 80);
|
||
const pulledTo = await screenAt(168, 80);
|
||
const C = await screenAt(196, 160);
|
||
await click(A.x, A.y);
|
||
await drag(B.x, B.y, pulledTo.x, pulledTo.y);
|
||
await click(C.x, C.y);
|
||
await click(A.x, A.y);
|
||
const penPoints = await pointsOf();
|
||
const curved = penPoints[1];
|
||
check(
|
||
'a drag with the pen grows both handles of the point, mirrored – a symmetric point',
|
||
penPoints.length === 3 &&
|
||
curved?.mode === 'symmetric' &&
|
||
near(curved.out?.x, 40) &&
|
||
near(curved.out?.y, 0) &&
|
||
near(curved.in?.x, -40) &&
|
||
near(curved.in?.y, 0),
|
||
JSON.stringify(penPoints),
|
||
);
|
||
|
||
await clickText('Select');
|
||
await click(B.x, B.y);
|
||
const picked = await pointType();
|
||
check(
|
||
'a click on that point picks it, and the header names its type',
|
||
picked.text === 'Point: Symmetric' && picked.enabled && picked.kind === 'symmetric',
|
||
JSON.stringify(picked),
|
||
);
|
||
|
||
// The menu ticks its entries in the text itself, so a row reads "☐ Corner" or "☑ Corner".
|
||
const openPointMenu = async () => {
|
||
const button = await pointType();
|
||
await click(button.x, button.y);
|
||
return (await json('__gm.menuRows()')) ?? [];
|
||
};
|
||
const choose = async (label) => {
|
||
const rows = await openPointMenu();
|
||
const text = rows.find((row) => row.endsWith(` ${label}`));
|
||
const row = text && (await inOverlay(text));
|
||
if (!row) throw new Error(`no "${label}" in the point type menu: ${JSON.stringify(rows)}`);
|
||
await click(row.x, row.y);
|
||
await settle();
|
||
};
|
||
|
||
const offered = await openPointMenu();
|
||
check(
|
||
'the point type menu offers the four types and ticks the one the point is',
|
||
offered.join('|') === '☐ Straight|☐ Corner|☐ Smooth|☑ Symmetric',
|
||
JSON.stringify(offered),
|
||
);
|
||
await press('Escape');
|
||
await settle();
|
||
const dragOut = async (x, y) => {
|
||
const point = (await pointsOf())[1];
|
||
const from = await screenAt(point.x + point.out.x, point.y + point.out.y);
|
||
const to = await screenAt(x, y);
|
||
await drag(from.x, from.y, to.x, to.y);
|
||
return (await pointsOf())[1];
|
||
};
|
||
|
||
await choose('Corner');
|
||
const cusp = await dragOut(128, 40);
|
||
check(
|
||
'on a corner a dragged handle leaves the other one where it was',
|
||
cusp.mode === null &&
|
||
near(cusp.out.x, 0) &&
|
||
near(cusp.out.y, -40) &&
|
||
near(cusp.in.x, -40) &&
|
||
near(cusp.in.y, 0),
|
||
JSON.stringify(cusp),
|
||
);
|
||
|
||
await choose('Smooth');
|
||
const lined = (await pointsOf())[1];
|
||
const smoothDragged = await dragOut(128 + 60, 80);
|
||
const inLength = Math.hypot(smoothDragged.in.x, smoothDragged.in.y);
|
||
check(
|
||
'Smooth lines the handles up, and dragging one turns the other round at its own length',
|
||
lined.mode === 'smooth' &&
|
||
smoothDragged.mode === 'smooth' &&
|
||
near(smoothDragged.out.x, 60) &&
|
||
near(smoothDragged.out.y, 0) &&
|
||
smoothDragged.in.x < 0 &&
|
||
near(smoothDragged.in.y, 0) &&
|
||
near(inLength, Math.hypot(lined.in.x, lined.in.y)) &&
|
||
!near(inLength, 60),
|
||
JSON.stringify({ lined, smoothDragged }),
|
||
);
|
||
await shot('47-point-smooth');
|
||
|
||
await choose('Straight');
|
||
const straight = (await pointsOf())[1];
|
||
check(
|
||
'Straight takes both handles away, and the header says so',
|
||
straight.in === null && straight.out === null && (await pointType()).text === 'Point: Straight',
|
||
JSON.stringify({ straight, header: await pointType() }),
|
||
);
|
||
|
||
// 47h (#187). Two squares are combined the way a user does it: a click picks the lower one, a
|
||
// Ctrl+click adds the upper one, and Subtract in the inspector cuts the upper out of the lower.
|
||
// The squares go into the model – drawing them is what 47c measured.
|
||
const square = (id, name, fill, x, y) => ({
|
||
id,
|
||
name,
|
||
fill,
|
||
opacity: 1,
|
||
rings: [
|
||
{
|
||
closed: true,
|
||
points: [
|
||
{ x, y },
|
||
{ x: x + 100, y },
|
||
{ x: x + 100, y: y + 100 },
|
||
{ x, y: y + 100 },
|
||
],
|
||
},
|
||
],
|
||
});
|
||
await mutate(`
|
||
const art = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(madeId)});
|
||
art.shapes = ${JSON.stringify([square('va-left', 'Left', 0xcc3344, 40, 60), square('va-right', 'Right', 0x3344cc, 100, 100)])};
|
||
__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(madeId)} })`);
|
||
await settle();
|
||
const selectedIds = () => json('__gm.vectorArt.selectedIds()');
|
||
const leftOnly = await screenAt(60, 80);
|
||
const rightOnly = await screenAt(180, 180);
|
||
await click(leftOnly.x, leftOnly.y);
|
||
await settle();
|
||
check(
|
||
'a click picks one shape, and the inspector asks for a second one to combine',
|
||
JSON.stringify(await selectedIds()) === '["va-left"]' &&
|
||
(await t.has('Ctrl+click a second shape to combine.')),
|
||
JSON.stringify(await selectedIds()),
|
||
);
|
||
|
||
await click(rightOnly.x, rightOnly.y, 'left', { ctrl: true });
|
||
await settle();
|
||
check(
|
||
'Ctrl+click adds the other shape, and the inspector names the lowest as the one that stays',
|
||
JSON.stringify(await selectedIds()) === '["va-left","va-right"]' &&
|
||
(await t.has('The result keeps "Left", the lowest.')),
|
||
JSON.stringify(await selectedIds()),
|
||
);
|
||
await shot('47-combine-selected');
|
||
|
||
await clickText('Subtract');
|
||
await settle();
|
||
const cut = await json(`__gm.vectorArt.shapes(${JSON.stringify(madeId)})`);
|
||
const ring = cut?.[0]?.rings[0]?.points ?? [];
|
||
const area = Math.abs(
|
||
ring.reduce((sum, p, i) => {
|
||
const q = ring[(i + 1) % ring.length];
|
||
return sum + p.x * q.y - q.x * p.y;
|
||
}, 0) / 2,
|
||
);
|
||
check(
|
||
'Subtract leaves the lower square with the corner the upper one covered cut away',
|
||
cut?.length === 1 &&
|
||
cut[0].id === 'va-left' &&
|
||
cut[0].fill === 0xcc3344 &&
|
||
cut[0].rings.length === 1 &&
|
||
ring.length === 6 &&
|
||
near(area, 100 * 100 - 40 * 60) &&
|
||
JSON.stringify(await selectedIds()) === '["va-left"]',
|
||
JSON.stringify({ cut, area, selected: await selectedIds() }),
|
||
);
|
||
|
||
// The tab paints the result: the fill where the lower square is left, none where it was cut away –
|
||
// neither red nor the blue of the upper square, which lay there before and would pass for "not red".
|
||
const keptPixel = await json(`__gm.pixelAt(${leftOnly.x}, ${leftOnly.y})`);
|
||
const inCut = await screenAt(120, 130);
|
||
const cutPixel = await json(`__gm.pixelAt(${inCut.x}, ${inCut.y})`);
|
||
check(
|
||
'the tab paints the cut: the fill remains beside it, and inside it the canvas is empty',
|
||
colourDistance(keptPixel, '#cc3344') < 14 &&
|
||
colourDistance(cutPixel, '#cc3344') > 60 &&
|
||
colourDistance(cutPixel, '#3344cc') > 60,
|
||
JSON.stringify({ keptPixel, cutPixel }),
|
||
);
|
||
await shot('47-combine-subtracted');
|
||
|
||
await undo();
|
||
await settle();
|
||
const restored = await json(`__gm.vectorArt.shapes(${JSON.stringify(madeId)})`);
|
||
check(
|
||
'one undo brings both squares back as they were',
|
||
restored?.map((s) => s.id).join(',') === 'va-left,va-right' && restored[0].rings[0].points.length === 4,
|
||
JSON.stringify(restored?.map((s) => ({ id: s.id, points: s.rings[0]?.points.length }))),
|
||
);
|
||
}
|