Scale, turn and mirror the shapes of a vector drawing (#291)
A shape could only be moved and reshaped point by point. Whoever
wanted it larger or turned pulled every point alone, and for several
shapes or a group there was no way at all. The answers in the issue
settle the rest: the change is baked into the points, nothing of it
is remembered, and the line width stays.
- With Select the selection stands in a frame of eight squares and a
knob, drawn like the one of the scene view. It holds one shape,
several shapes or a group, the same selection that is dragged as
one.
- A corner square scales from the opposite corner, a square on an
edge in its own direction. Shift keeps the proportions, Alt scales
about the middle.
- The knob turns round the middle of the frame, with Shift in steps
of 15 degrees. After the pull the frame stands upright again.
- The inspector has a band TRANSFORM with X, Y, Width, Height and
Rotate by, and the buttons Flip horizontal and Flip vertical.
- Points, handles, gradients and texture tiles are taken through the
same matrix. The rule is a pure function in vectorTransform.ts,
transformShapesCommand makes one undo step of any number of
shapes.
- A press goes to a point or a handle first, then to the frame, then
to a line. shapeFrame.ts holds where the handles are and what a
pull of each means.
Decided here:
- The frame stands 15 pixels off the shapes. The points of a shape
lie on its bounds, so a square on the bounds would cover them.
- The knob only turns. In the scene view it scales as well.
- A side cannot be pulled through the opposite one, the frame keeps
at least 1 pixel.
- Coordinates are rounded to a thousandth of a pixel, so a quarter
turn is exact.
- A texture tile stays upright under a turn and grows to the bounds
of the turned tile. The bands of a gradient stay square to its
axis under an uneven scale.
- The switch Scale stroke of the proposal is not built.
Found on the way:
- The inspector of a shape is now taller than the window. Two checks
of module 47 no longer found their row or button on the screen and
use the new hook inspectorButton, which scrolls there first.
- Not solved: with a shape at the top edge of the view the knob lies
outside the tab. The wiki names it under the limits.
Taken over from #217: the e2e check for two selected shapes without
a group, and unit tests for the snap of the common offset and for
the gradients of the shapes dragged along.
Tests:
- Gate on melektaus-atx: 209 files, 3176 tests (3121 + 55), exit 0
- e2e 47,48 on melektaus-atx: 112 checks, all pass
- No full run and no smokes. The sum of 1664 (1660 + 4) is reckoned,
1660 is what CI counted for 6d79cf5.
- 55 unit tests: 25 in tests/core/vector-transform.test.ts and 30 in
tests/editor/shape-frame.test.ts
- Four e2e checks in block 47k of module 47: pull a corner, turn
with Shift, type width and rotation, drag two shapes without a
group
- 47 unit counter-probes and four browser counter-probes all fail
their tests. Four of the unit ones did not at first: two showed
dead code, which is gone, two got tests.
- Not checked in the browser: the edge squares, Alt, the cursors,
the flip buttons and the rows X, Y and Height
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6d79cf59a3
commit
345289982b
14 files changed
+2026
-28
No files matched your search
+196
-8
@@ -82,7 +82,6 @@ export async function vectorArt(t) {
|
||||
clickText,
|
||||
json,
|
||||
evaluate,
|
||||
locate,
|
||||
mutate,
|
||||
press,
|
||||
settle,
|
||||
@@ -187,6 +186,12 @@ export async function vectorArt(t) {
|
||||
JSON.stringify({ current: await json('__gm.vectorArt.current()'), info }),
|
||||
);
|
||||
|
||||
/** A button among the rows of the inspector, which may stand below the fold (#291). */
|
||||
const clickRowButton = async (text) => {
|
||||
const button = await json(`__gm.inspectorButton(${JSON.stringify(text)})`);
|
||||
if (!button) throw new Error(`no button "${text}" in the inspector`);
|
||||
await click(button.x, button.y);
|
||||
};
|
||||
await clickText('Pen');
|
||||
check('the pen is the tool a press uses', (await json('__gm.vectorArt.tool()')) === 'pen');
|
||||
|
||||
@@ -380,14 +385,18 @@ export async function vectorArt(t) {
|
||||
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')),
|
||||
// By its control, which scrolls the row into view: with the TRANSFORM rows (#291) over it the
|
||||
// inspector is taller than the window.
|
||||
Boolean(outlined?.stroke) &&
|
||||
outlined.stroke.width === 2 &&
|
||||
Boolean(await json("__gm.inspectorField('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 clickRowButton('To front');
|
||||
await settle();
|
||||
const nowField = await json(`__gm.pixelAt(${Math.round(covered.x)}, ${Math.round(covered.y)})`);
|
||||
check(
|
||||
@@ -416,7 +425,7 @@ export async function vectorArt(t) {
|
||||
);
|
||||
|
||||
const before47e = (await shapeIds()).length;
|
||||
await clickText('Add shape');
|
||||
await clickRowButton('Add shape');
|
||||
await settle();
|
||||
const added = await shapeIds();
|
||||
check(
|
||||
@@ -441,7 +450,7 @@ export async function vectorArt(t) {
|
||||
|
||||
// Delete takes a shape away however it was picked. A row of the list leaves nothing focused, so the
|
||||
// key reaches the editor's shortcuts rather than the canvas.
|
||||
await clickText('Add shape');
|
||||
await clickRowButton('Add shape');
|
||||
await settle();
|
||||
const spare = (await shapeIds()).at(-1);
|
||||
const spareRow = await json(`__gm.vectorArt.shapeRowAt(${JSON.stringify(spare)})`);
|
||||
@@ -771,7 +780,7 @@ export async function vectorArt(t) {
|
||||
);
|
||||
await shot('47-combine-selected');
|
||||
|
||||
await clickText('Subtract');
|
||||
await clickRowButton('Subtract');
|
||||
await settle();
|
||||
const cut = await json(`__gm.vectorArt.shapes(${JSON.stringify(madeId)})`);
|
||||
const ring = cut?.[0]?.rings[0]?.points ?? [];
|
||||
@@ -846,7 +855,7 @@ export async function vectorArt(t) {
|
||||
await click(onDisc.x, onDisc.y);
|
||||
await click(onCover.x, onCover.y, 'left', { ctrl: true });
|
||||
await settle();
|
||||
await clickText('Subtract');
|
||||
await clickRowButton('Subtract');
|
||||
await settle();
|
||||
const carved = await json(`__gm.vectorArt.shapes(${JSON.stringify(madeId)})`);
|
||||
const arc = carved?.[0]?.rings[0]?.points ?? [];
|
||||
@@ -1158,7 +1167,7 @@ export async function vectorArt(t) {
|
||||
await click(onOne.x, onOne.y);
|
||||
await click(onTwo.x, onTwo.y, 'left', { shift: true });
|
||||
await settle();
|
||||
await clickText('Group');
|
||||
await clickRowButton('Group');
|
||||
await settle();
|
||||
const grouped = (await layers190())[0];
|
||||
const groupId = grouped.rows.find((r) => r.group)?.id;
|
||||
@@ -1275,6 +1284,185 @@ export async function vectorArt(t) {
|
||||
colourDistance(await tabPixel(200, 40), GREEN) < 14,
|
||||
JSON.stringify({ hiddenRed, pressedHidden, shownRed }),
|
||||
);
|
||||
|
||||
// 47k. The frame round the selection (#291): its handles scale and turn what is selected, the
|
||||
// rows of the inspector do the same by number, and two shapes that are not grouped go together.
|
||||
await mutate(`
|
||||
const art291 = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(madeId)});
|
||||
art291.layers = [{ id: 'va-base', name: 'Layer 1', items: ${JSON.stringify([
|
||||
{ ...square('va-one', 'One', 0xcc3344, 10, 10), rings: [squareRing(10, 90, 60)] },
|
||||
{ ...square('va-two', 'Two', 0x3344cc, 90, 10), rings: [squareRing(90, 90, 60)] },
|
||||
{ ...square('va-three', 'Three', 0x33aa55, 170, 10), rings: [squareRing(170, 90, 60)] },
|
||||
])} }];
|
||||
__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(madeId)} })`);
|
||||
await settle();
|
||||
const frame291 = () => json('__gm.vectorArt.frame()');
|
||||
const pointsOf291 = async (id) =>
|
||||
(await json(`__gm.vectorArt.shapes(${JSON.stringify(madeId)})`))
|
||||
.find((s) => s.id === id)
|
||||
?.rings[0].points.map((p) => [p.x, p.y]);
|
||||
const reads291 = (points, expected) =>
|
||||
points?.length === expected.length &&
|
||||
points.every((p, i) => near(p[0], expected[i][0]) && near(p[1], expected[i][1]));
|
||||
const zoom291 = (await screenAt(1, 0)).x - (await screenAt(0, 0)).x;
|
||||
// The squares stand in the middle of the canvas: the knob over a frame at its top edge would
|
||||
// lie outside the tab.
|
||||
const one291 = await screenAt(40, 120);
|
||||
const two291 = await screenAt(120, 120);
|
||||
|
||||
// One shape picked: the frame stands off its bounds, and its far corner pulled 60 pixels out
|
||||
// doubles the square from the corner opposite.
|
||||
const noFrame = await frame291();
|
||||
await click(one291.x, one291.y);
|
||||
await settle();
|
||||
const framed = await frame291();
|
||||
const cornerOne = await screenAt(70, 150);
|
||||
const handle = framed.corners[2];
|
||||
await shot('47-frame');
|
||||
await drag(handle.x, handle.y, handle.x + 60 * zoom291, handle.y + 60 * zoom291, { steps: 8 });
|
||||
await settle();
|
||||
const scaledOne = await pointsOf291('va-one');
|
||||
const redGrown = await tabPixel(110, 190);
|
||||
await undo();
|
||||
await settle();
|
||||
const unscaledOne = await pointsOf291('va-one');
|
||||
check(
|
||||
'a corner of the frame scales the selected shape from the opposite corner, in one undo step',
|
||||
noFrame === null &&
|
||||
near(handle.x - cornerOne.x, 15) &&
|
||||
near(handle.y - cornerOne.y, 15) &&
|
||||
framed.edges.length === 4 &&
|
||||
reads291(scaledOne, [
|
||||
[10, 90],
|
||||
[130, 90],
|
||||
[130, 210],
|
||||
[10, 210],
|
||||
]) &&
|
||||
colourDistance(redGrown, RED) < 14 &&
|
||||
reads291(unscaledOne, [
|
||||
[10, 90],
|
||||
[70, 90],
|
||||
[70, 150],
|
||||
[10, 150],
|
||||
]),
|
||||
JSON.stringify({ noFrame, handle, cornerOne, scaledOne, redGrown, unscaledOne }),
|
||||
);
|
||||
|
||||
// Two shapes picked without a group (#217): a drag on one moves both by the same way.
|
||||
await click(two291.x, two291.y, 'left', { shift: true });
|
||||
await settle();
|
||||
const pair = await json('__gm.vectorArt.selectedIds()');
|
||||
await drag(one291.x, one291.y, one291.x, one291.y + 80 * zoom291, { steps: 8 });
|
||||
await settle();
|
||||
const pairOne = await pointsOf291('va-one');
|
||||
const pairTwo = await pointsOf291('va-two');
|
||||
const pairThree = await pointsOf291('va-three');
|
||||
const blueMoved = await tabPixel(120, 200);
|
||||
await undo();
|
||||
await settle();
|
||||
check(
|
||||
'two selected shapes that are not grouped are dragged together, and one undo puts both back',
|
||||
pair.join() === 'va-one,va-two' &&
|
||||
near(pairOne?.[0][1], 170) &&
|
||||
near(pairTwo?.[0][1], 170) &&
|
||||
near(pairTwo?.[0][0], 90) &&
|
||||
near(pairThree?.[0][1], 90) &&
|
||||
colourDistance(blueMoved, BLUE) < 14 &&
|
||||
near((await pointsOf291('va-one'))?.[0][1], 90) &&
|
||||
near((await pointsOf291('va-two'))?.[0][1], 90),
|
||||
JSON.stringify({ pair, pairOne, pairTwo, pairThree, blueMoved }),
|
||||
);
|
||||
|
||||
// The knob with Shift held: from above the middle to the right of it, a quarter turn of both
|
||||
// shapes round the middle of their common frame, which is at 80, 120.
|
||||
const both = await frame291();
|
||||
const middle291 = await screenAt(80, 120);
|
||||
const reach = middle291.y - both.knob.y;
|
||||
const emptyBefore = await tabPixel(80, 160);
|
||||
await drag(both.knob.x, both.knob.y, middle291.x + reach, middle291.y + 6, {
|
||||
steps: 10,
|
||||
modifiers: { shift: true },
|
||||
});
|
||||
await settle();
|
||||
const turnedOne = await pointsOf291('va-one');
|
||||
const turnedTwo = await pointsOf291('va-two');
|
||||
const blueTurned = await tabPixel(80, 160);
|
||||
const upright = await frame291();
|
||||
await shot('47-frame-turned');
|
||||
await undo();
|
||||
await settle();
|
||||
check(
|
||||
'the knob turns the selection round the middle of its frame, snapping to a quarter with Shift',
|
||||
reads291(turnedOne, [
|
||||
[110, 50],
|
||||
[110, 110],
|
||||
[50, 110],
|
||||
[50, 50],
|
||||
]) &&
|
||||
reads291(turnedTwo, [
|
||||
[110, 130],
|
||||
[110, 190],
|
||||
[50, 190],
|
||||
[50, 130],
|
||||
]) &&
|
||||
colourDistance(emptyBefore, BLUE) > 60 &&
|
||||
colourDistance(blueTurned, BLUE) < 14 &&
|
||||
// Baked in: the frame is upright again, its knob over the middle of the turned pair.
|
||||
near(upright.knob.x, middle291.x) &&
|
||||
upright.knob.y < middle291.y &&
|
||||
near((await pointsOf291('va-one'))?.[0][0], 10),
|
||||
JSON.stringify({
|
||||
turnedOne,
|
||||
turnedTwo,
|
||||
emptyBefore,
|
||||
blueTurned,
|
||||
upright: upright.knob,
|
||||
middle: middle291,
|
||||
}),
|
||||
);
|
||||
|
||||
// The rows of the inspector: a typed width stretches both shapes from the left edge, and a
|
||||
// typed turn is baked in, after which its row reads 0 again.
|
||||
const widthField = await json("__gm.inspectorField('Width', true)");
|
||||
await click(widthField.x, widthField.y);
|
||||
await press('a', { ctrl: true });
|
||||
await typeText('280');
|
||||
await press('Enter');
|
||||
await settle();
|
||||
const wideOne = await pointsOf291('va-one');
|
||||
const wideTwo = await pointsOf291('va-two');
|
||||
await undo();
|
||||
await settle();
|
||||
const turnField = await json("__gm.inspectorField('Rotate by', true)");
|
||||
await click(turnField.x, turnField.y);
|
||||
await press('a', { ctrl: true });
|
||||
await typeText('180');
|
||||
await press('Enter');
|
||||
await settle();
|
||||
const halfOne = await pointsOf291('va-one');
|
||||
const turnText = await json("__gm.inspectorLink('Rotate by')?.field?.value ?? null");
|
||||
await undo();
|
||||
await settle();
|
||||
check(
|
||||
'a typed width stretches the selection and a typed turn is baked in, each one undo step',
|
||||
reads291(wideOne, [
|
||||
[10, 90],
|
||||
[130, 90],
|
||||
[130, 150],
|
||||
[10, 150],
|
||||
]) &&
|
||||
near(wideTwo?.[0][0], 170) &&
|
||||
near(wideTwo?.[1][0], 290) &&
|
||||
reads291(halfOne, [
|
||||
[150, 150],
|
||||
[90, 150],
|
||||
[90, 90],
|
||||
[150, 90],
|
||||
]) &&
|
||||
Number(turnText) === 0 &&
|
||||
near((await pointsOf291('va-one'))?.[0][0], 10),
|
||||
JSON.stringify({ wideOne, wideTwo, halfOne, turnText }),
|
||||
);
|
||||
}
|
||||
|
||||
/** A square ring of `size` pixels with its corner at `x`, `y`. */
|
||||
|
||||
Reference in new issue
Block a user