Put the shapes of a vector drawing on layers and in groups (#190)
game-maker / gate (push) Successful in 2m41s
game-maker / suite (push) Failing after 7h24m17s

A vector drawing was one flat list of shapes. With many shapes the
list got hard to read, and several shapes could not be kept or moved
together. Now the shapes stand on layers, as in pixel art, and can be
put into groups that nest.

- A drawing has layers, each with a name and an eye. A hidden layer
  is neither in the tab nor in the picture a sprite wears, and its
  shapes cannot be pressed. One layer is the one worked on: the pen
  and Add shape draw onto it, and SHAPES lists what is on it.
- The inspector has a list LAYERS over the shapes. Its plus adds a
  layer, the cross on a row removes it with all on it, the last one
  stays. A double-click renames, a drag sorts.
- Group (Ctrl+G) puts the selected shapes into a group, Ungroup
  (Ctrl+Shift+G) takes it away. A group stands over its rows in the
  list, the rows one step in, and a row is dragged into a group or
  out of it.
- In the tab a press on a shape takes its outermost group, and a drag
  moves the group as one undo step.
- The model has layers with a tree of shapes and groups instead of a
  flat shapes array. The rules are pure functions in
  core/model/vectorTree.ts. A drawing saved before opens with all its
  shapes on one layer.
- SortableList takes rows with a depth and drops into a tree. The
  flat lists of tilemap and pixel art run as before.

Decided here:

- A group has no transformation of its own and cannot be hidden or
  locked. Rotating and scaling is the follow-up #291.
- A group is never empty: the one that loses its last shape goes
  with it.
- Several selected shapes are dragged together, grouped or not, and
  a click on a shape of the selection keeps the selection. This is
  what #217 asked for.
- Shift+click selects like Ctrl+click. With Select, Shift pans only
  beside the shapes, as in the scene view. The middle button pans
  anywhere, and with the pen and Paint Shift pans as before.
- A row of the list picks one shape of a group alone.
- Shapes change their layer through a row Layer in the inspector,
  which is there from two layers on. Rows are not dragged between
  layers.
- A group cannot span two layers, the button is off then.
- The pen does not draw onto a hidden layer, the hint says so.
- Groups are always shown unfolded.
- The four order buttons work inside the own group or layer.

Tests:
- Gate: 204 files, 3067 tests (3008 + 59), exit 0
- e2e 47,48: 108 checks. e2e 39,40,47,48 before the Shift change:
  209 checks. No full run, the sum of 1645 (1639 + 6) is reckoned
  and CI measures it.
- 59 unit tests: 36 in tests/core/vector-tree.test.ts, 23 in
  tests/editor/vector-layers.test.ts
- Six e2e checks with a real mouse in block 47j
- 47 unit counter-probes for layers and groups and three for the
  Shift click all fail tests, run on stein2
- Three browser counter-probes fail their checks
- Two screenshots looked at (group in the list, hidden layer)
- Only unit tested: the row Layer, sorting and removing layers,
  copying a group, the Shift pan beside the shapes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-01 23:13:43 +02:00
1 parent 393caadc9d
commit 2ff3a78607
30 files changed
+2625 -408

No files matched your search

+169 -5
View File
@@ -100,7 +100,8 @@ export async function vectorArt(t) {
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)} });
props: { canvasWidth: ${CANVAS}, canvasHeight: ${CANVAS} },
layers: [{ id: 'va-layer', name: 'Layer 1', items: ${JSON.stringify(SHAPES)} }] });
const sprite = structuredClone(project.assets.find((a) => a.id === 'crate'));
sprite.id = 'va-sprite';
sprite.name = 'Emblem sprite';
@@ -163,7 +164,7 @@ export async function vectorArt(t) {
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)}`);
art.layers[0].items[0].fill = 0x${REDRAWN.slice(1)}`);
await settle();
await sleep(300);
const after = await json("__gm.drawnPicture('va-art')");
@@ -745,7 +746,7 @@ export async function vectorArt(t) {
});
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)])};
art.layers[0].items = ${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()');
@@ -837,7 +838,7 @@ export async function vectorArt(t) {
};
await mutate(`
const art = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(madeId)});
art.shapes = ${JSON.stringify([DISC, square('va-cover', 'Cover', 0x3344cc, 20, 20)])};
art.layers[0].items = ${JSON.stringify([DISC, square('va-cover', 'Cover', 0x3344cc, 20, 20)])};
__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(madeId)} })`);
await settle();
const onDisc = await screenAt(170, 120);
@@ -892,7 +893,7 @@ export async function vectorArt(t) {
};
await mutate(`
const artPaint = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(madeId)});
artPaint.shapes = ${JSON.stringify([SKY])};
artPaint.layers[0].items = ${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();
@@ -1124,4 +1125,167 @@ export async function vectorArt(t) {
JSON.stringify({ stroke: linedPaint?.stroke, fillTo: linedPaint?.paint?.to }),
);
await shot('47-paint-outline');
// 47j. Layers and groups (#190): three squares, grouped, moved as one, and a layer hidden.
const RED = '#cc3344';
const BLUE = '#3344cc';
const GREEN = '#33aa55';
await mutate(`
const art190 = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(madeId)});
art190.layers = [{ id: 'va-base', name: 'Layer 1', items: ${JSON.stringify([
{ ...square('va-one', 'One', 0xcc3344, 10, 10), rings: [squareRing(10, 10, 60)] },
{ ...square('va-two', 'Two', 0x3344cc, 90, 10), rings: [squareRing(90, 10, 60)] },
{ ...square('va-three', 'Three', 0x33aa55, 170, 10), rings: [squareRing(170, 10, 60)] },
])} }];
__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(madeId)} })`);
await evaluate(`__gm.openDocument('asset:${madeId}')`);
await clickText('Select');
await settle();
const layers190 = () => json(`__gm.vectorArt.layers(${JSON.stringify(madeId)})`);
const rowIds = (layer) => layer.rows.map((r) => `${r.id}:${r.depth}`).join(' ');
const cornerOf = async (id) =>
(await json(`__gm.vectorArt.shapes(${JSON.stringify(madeId)})`)).find((s) => s.id === id)?.rings[0]
.points[0];
const rowCentre = async (id) => {
const r = await json(`__gm.vectorArt.shapeRowAt(${JSON.stringify(id)})`);
return { x: r.x + r.w / 2, y: r.y + r.h / 2, h: r.h };
};
// Two shapes picked in the tab, then the Group button: one group where the upper one stood.
// The second is added with Shift, which does what Ctrl does in 47g.
const onOne = await screenAt(40, 40);
const onTwo = await screenAt(120, 40);
await click(onOne.x, onOne.y);
await click(onTwo.x, onTwo.y, 'left', { shift: true });
await settle();
await clickText('Group');
await settle();
const grouped = (await layers190())[0];
const groupId = grouped.rows.find((r) => r.group)?.id;
const nameX = async (id) => (await json(`__gm.vectorArt.shapeNameAt(${JSON.stringify(id)})`)).x;
const indent = (await nameX('va-two')) - (await nameX('va-three'));
check(
'Group puts the two selected shapes into a group, its rows one step in',
rowIds(grouped) === `va-three:0 ${groupId}:0 va-two:1 va-one:1` &&
(await nameX(groupId)) === (await nameX('va-three')) &&
indent === 14 &&
(await json('__gm.vectorArt.selectedIds()')).join() === 'va-one,va-two',
JSON.stringify({ rows: grouped.rows, indent, selected: await json('__gm.vectorArt.selectedIds()') }),
);
await shot('47-groups');
// The keys do the same from wherever the focus is: Ctrl+Shift+G takes the group away, Ctrl+G
// puts the same two shapes into a new one.
await press('g', { ctrl: true, shift: true });
await settle();
const ungrouped = rowIds((await layers190())[0]);
await press('g', { ctrl: true });
await settle();
const regrouped = (await layers190())[0];
check(
'Ctrl+Shift+G takes the group away and Ctrl+G groups the selection again',
ungrouped === 'va-three:0 va-two:0 va-one:0' &&
regrouped.rows.map((r) => `${r.name}:${r.depth}`).join(' ') === 'Three:0 Group 1:0 Two:1 One:1',
JSON.stringify({ ungrouped, regrouped: regrouped.rows }),
);
await undo();
await undo();
await settle();
// A press on one shape of the group takes the whole group, and a drag moves both in one step.
const onThree = await screenAt(200, 40);
await click(onThree.x, onThree.y);
await settle();
const alone = await json('__gm.vectorArt.selectedIds()');
const lower = await screenAt(40, 140);
await drag(onOne.x, onOne.y, lower.x, lower.y, { steps: 8 });
await settle();
const movedOne = await cornerOf('va-one');
const movedTwo = await cornerOf('va-two');
const tookGroup = await json('__gm.vectorArt.selectedIds()');
const redBelow = await tabPixel(40, 140);
const blueBelow = await tabPixel(120, 140);
await undo();
await settle();
const backOne = await cornerOf('va-one');
const backTwo = await cornerOf('va-two');
check(
'dragging one shape of a group moves the whole group, and one undo puts both back',
alone.join() === 'va-three' &&
tookGroup.join() === 'va-one,va-two' &&
near(movedOne.y, 110) &&
near(movedTwo.y, 110) &&
near(movedTwo.x, 90) &&
colourDistance(redBelow, RED) < 14 &&
colourDistance(blueBelow, BLUE) < 14 &&
near(backOne.y, 10) &&
near(backTwo.y, 10),
JSON.stringify({ alone, tookGroup, movedOne, movedTwo, redBelow, blueBelow, backOne, backTwo }),
);
// A row dragged under the group's last row: straight down it joins the group, the row's own
// depth being 0 it stays outside – so it is dragged one indent to the right as well.
const threeRow = await rowCentre('va-three');
const oneRow = await rowCentre('va-one');
await drag(threeRow.x, threeRow.y, threeRow.x + 14, oneRow.y + oneRow.h / 2 - 2, { steps: 8 });
await settle();
const joined = (await layers190())[0];
check(
'a row dragged to the end of a group and one indent right joins the group',
rowIds(joined) === `${groupId}:0 va-two:1 va-one:1 va-three:1`,
JSON.stringify(joined.rows),
);
await undo();
await settle();
// The plus of the layer list: a second layer over the first, which the pen then draws onto.
const plus190 = await json('__gm.vectorArt.layerAddAt()');
await click(plus190.x, plus190.y);
await settle();
const twoLayers = await layers190();
const upperId = twoLayers[1]?.id;
check(
'the plus adds a layer on top, and it is the one worked on',
twoLayers.length === 2 &&
twoLayers[1].active &&
twoLayers[1].rows.length === 0 &&
(await json('__gm.vectorArt.layerRows()')).join() === `${upperId},va-base`,
JSON.stringify(twoLayers.map((l) => ({ id: l.id, active: l.active, rows: l.rows.length }))),
);
// The eye of the lower layer: its shapes leave the tab, and a press there finds nothing.
const eye = await json("__gm.vectorArt.layerEyeAt('va-base')");
await click(eye.x, eye.y);
await settle();
const hiddenRed = await tabPixel(40, 40);
await click(onOne.x, onOne.y);
await settle();
const pressedHidden = await json('__gm.vectorArt.selectedIds()');
await shot('47-layer-hidden');
const eyeAgain = await json("__gm.vectorArt.layerEyeAt('va-base')");
await click(eyeAgain.x, eyeAgain.y);
await settle();
const shownRed = await tabPixel(40, 40);
check(
'the eye hides a layer in the tab, its shapes cannot be pressed, and the eye shows it again',
colourDistance(hiddenRed, RED) > 60 &&
pressedHidden.length === 0 &&
(await layers190())[0].hidden === false &&
colourDistance(shownRed, RED) < 14 &&
colourDistance(await tabPixel(200, 40), GREEN) < 14,
JSON.stringify({ hiddenRed, pressedHidden, shownRed }),
);
}
/** A square ring of `size` pixels with its corner at `x`, `y`. */
function squareRing(x, y, size) {
return {
closed: true,
points: [
{ x, y },
{ x: x + size, y },
{ x: x + size, y: y + size },
{ x, y: y + size },
],
};
}