Vector shapes can fill with gradients and textures (#186)
A vector shape could only be filled and outlined with one flat colour. Shading, glows and patterned surfaces had to be faked with stacks of shapes. Every shape now has a paint type for its fill and its outline. - Fill type: solid colour, linear gradient, radial gradient or texture. Line type offers the same without texture. - Colour stops are edited on a bar in the inspector, as agreed in the issue. A click on the bar adds a stop in the colour the gradient already has there, so adding never changes the picture. At least two stops remain. - The new Paint tool in the tab shows two handles for the selected shape: axis start and end, centre and radius, or the corners of one texture tile. "Handles: Fill / Outline" picks which paint is edited. - Moving a whole shape moves its gradients and tile with it. Every gesture is one undo step. - A texture fill always tiles, because Pixi turns a non-repeating pattern into a repeating one anyway. When switched on, one tile covers the whole shape. - Only uploaded textures and frames can be a fill, never a drawing. A drawing filled with itself would rasterise itself mid-raster. - The fingerprint of a drawing includes the texture and frame a fill points to, so the picture redraws when the image is swapped. Findings: - graphics.destroy(true) also destroyed the textures of the fills. With a texture fill it deleted the tile slice and the next redraw crashed on reading 'scaleMode' of null. It is now destroy(). - The gradient cache destroyed old gradients itself, which gave 20 Pixi warnings "destroyed while still bound". They are now handed to Pixi's texture GC with autoGarbageCollect switched on. The console of module 47 is empty again, the last CI log had 0 of them. Tests: - ./check.sh: 135 files, 2011 tests, exit 0 (before 134 / 1986) - npm run build: exit 0 - GM_STEPS=47, freshly seeded: ALL PASSED, 54 checks (before 44), 0 console warnings - 25 new unit tests, 10 new e2e checks in block 47i - Four counter-checks bite, originals restored and verified with cmp - Check sum 1232 in README and wiki, calculated (1222 + 10) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1116818011
commit
592ea942d9
22 files changed
+2090
-98
No files matched your search
+245
-1
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -14,7 +15,8 @@ import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
|
||||
* 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).
|
||||
* 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;
|
||||
@@ -682,4 +684,246 @@ export async function vectorArt(t) {
|
||||
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`,
|
||||
);
|
||||
const halvesEntry = halvesName && (await inOverlay(halvesName));
|
||||
if (halvesEntry) await click(halvesEntry.x, halvesEntry.y);
|
||||
else await press('Escape');
|
||||
await settle();
|
||||
await sleep(300);
|
||||
const tiled = (await sky())?.paint;
|
||||
const leftHalf = await tabPixel(60, 100);
|
||||
const rightHalf = await tabPixel(196, 100);
|
||||
check(
|
||||
'Texture offers the uploaded pictures but no drawing, and fills the shape with the one picked',
|
||||
Boolean(halvesEntry) &&
|
||||
!pictureOffers.includes('Emblem') &&
|
||||
tiled?.type === 'texture' &&
|
||||
tiled.texture === halves?.id &&
|
||||
colourDistance(leftHalf, HALVES_LEFT) < 20 &&
|
||||
colourDistance(rightHalf, HALVES_RIGHT) < 20,
|
||||
JSON.stringify({ pictureOffers, 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');
|
||||
}
|
||||
Reference in new issue
Block a user