Stage 4 of #129. Until now the inspector showed the rows of the asset when a shape was selected, so there was no way to give a shape a colour, an outline or a place in the drawing order without editing the project file. The rows for a shape now sit where every other selection puts its rows. - SHAPES lists every shape of the asset, topmost row drawn last, the same way a layer list reads. Selecting the asset itself shows the same list below its own rows, with no shape selected. - Each row paints its shape with the commands the rasterizer uses (shapeDrawing plus paintDrawing) into a 22 px swatch, the whole canvas scaled in, so the row also says where on the canvas the shape sits. - The four order buttons from the issue (forward, to front, backward, to back) sit in two rows of two, and a row can be dragged to another place with a marker line. They are disabled when there is nothing to move. - VECTOR SHAPE carries name, fill, opacity and an outline checkbox, the checkbox adding and removing the stroke instead of hiding it; line colour and width appear with it. A dragged colour slider is one undo step, the style rows going through previewingHandlers like every other row. - A line width of 0 is no stroke, which the command rejects, so the row clamps at 0.1. Findings: - locate('Outline') aims beside the control, because it gives the centre of the label text and a wider label pushes a fixed offset out of the field. __gm.inspectorField(label) knows the centre of the control. - A click into a text field sets the cursor and selects nothing, so a typed hex landed appended to the old one and parseHex returned null without anything failing - typeText only checks that the text is contained. A Ctrl+A goes first now. - Four buttons side by side cut "Backward" to "Backw...", which only the screenshot showed. Two rows of two now, as in the tilemap panel. Tests: ./check.sh 128 files, 1897 tests (was 127/1883). Full e2e suite locally instead of only CI because the change sits in the inspector and touches every selection: ALL PASSED - 1179 checks (was 1172), module 47 at 24 checks (was 17). Three counter-checks hold: not reversing the list fails 1 unit test and 2 e2e checks, dropping the gap correction when dropping a row fails 2 unit tests and the drag check, and a constant signature kills module 47 because the outline rows never appear. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
345 lines
13 KiB
JavaScript
345 lines
13 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.
|
||
*/
|
||
|
||
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,
|
||
} = 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),
|
||
);
|
||
|
||
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()') }),
|
||
);
|
||
}
|