Files
feuerwerk/tests/editor/vector-layers.test.ts
T
adminandClaude Opus 5.5 2ff3a78607
game-maker / gate (push) Successful in 2m41s
game-maker / suite (push) Failing after 7h24m17s
Put the shapes of a vector drawing on layers and in groups (#190)
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>
2026-10-01 23:13:43 +02:00

436 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Layers and groups of vector art in the editor (#190): the two lists of the inspector, the buttons
* that group, and what a press in the tab does to a shape that stands in a group or on a hidden layer.
*/
import { CanvasTextMetrics, type FederatedPointerEvent } from 'pixi.js';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { buildAsset } from '../../src/core/commands/assetCommands';
import {
AddVectorLayerCommand,
GroupVectorShapesCommand,
SetVectorLayerHiddenCommand,
} from '../../src/core/commands/vectorArtCommands';
import type { VectorArtAsset, VectorItem, VectorShape } from '../../src/core/model/Asset';
import { createEmptyProject } from '../../src/core/model/Project';
import type { Vec2 } from '../../src/core/model/Properties';
import { allShapes } from '../../src/core/model/vectorTree';
import type { InspectorHost } from '../../src/editor/inspector/host';
import {
buildShapeListSection,
buildShapeSection,
currentLayerList,
currentShapeList,
moveShapesToLayer,
pickShapeRow,
shapeListSignature,
shapeSignature,
} from '../../src/editor/inspector/shapeSection';
import { EditorState, shapeSelection } from '../../src/editor/state';
import { activeLayerOf, pickVectorLayer } from '../../src/editor/vectorart/activeLayer';
import { groupShapes, ungroupShapes } from '../../src/editor/vectorart/grouping';
import { VectorArtCanvas } from '../../src/editor/vectorart/VectorArtCanvas';
import { Button, Label, VStack, type Widget } from '../../src/ui';
import { resetRenderLoop } from '../../src/ui/render';
import { PaintCache } from '../../src/viewport/vectorArtPicture';
beforeAll(() => {
CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never;
PaintCache.prototype.gradient = (() => null) as never;
});
beforeEach(() => resetRenderLoop());
/** A square of 20 pixels with its corner at `x`, `y`. */
function square(id: string, x: number, y = 0): VectorShape {
return {
id,
name: id.toUpperCase(),
fill: 0x336699,
opacity: 1,
rings: [
{
closed: true,
points: [
{ x, y },
{ x: x + 20, y },
{ x: x + 20, y: y + 20 },
{ x, y: y + 20 },
],
},
],
};
}
function text(items: readonly VectorItem[]): string {
return items.map((item) => ('items' in item ? `${item.id}[${text(item.items)}]` : item.id)).join(' ');
}
/** A drawing with the squares a, b and c side by side on its one layer. */
function rig() {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
const asset = buildAsset('vectorart', 'Drawing') as VectorArtAsset;
asset.layers[0]!.items.push(square('a', 0), square('b', 40), square('c', 80));
state.project.assets.push(asset);
const host: InspectorHost = {
state,
body: new VStack(),
fields: new Map(),
nameField: null,
project: () => state.project,
liveVariables: () => null,
};
/** The rows of the current selection, built anew as the inspector does after a change. */
const build = (): void => {
host.body.removeChildren();
host.fields.clear();
const s = state.selection;
if (s?.kind === 'shape') buildShapeSection(host, asset.id, s.shapeId, s.shapeIds);
else buildShapeListSection(host, asset);
};
const widgets = <T extends Widget>(type: new (...args: never[]) => T): T[] => {
const found: T[] = [];
const walk = (widget: Widget): void => {
if (widget instanceof type) found.push(widget);
for (const child of widget.children) walk(child as Widget);
};
walk(host.body);
return found;
};
const button = (label: string) => widgets(Button).find((b) => b.text === label)!;
const texts = () => widgets(Label).map((label) => label.text);
const group = (...ids: string[]): string => {
const command = new GroupVectorShapesCommand(asset.id, ids);
state.execute(command);
return command.groupId;
};
const addLayer = (): string => {
const command = new AddVectorLayerCommand(asset.id);
state.execute(command);
return command.layerId;
};
const tree = () => asset.layers.map((layer) => text(layer.items));
return { state, asset, host, build, button, texts, group, addLayer, tree };
}
describe('the lists of the inspector', () => {
it('lists the layers top first and marks the one worked on', () => {
const r = rig();
const base = r.asset.layers[0]!.id;
const upper = r.addLayer();
r.build();
expect(currentLayerList()!.shownIds).toEqual([upper, base]);
// Nothing was picked yet, so the topmost layer is the one worked on – and it is empty.
expect(activeLayerOf(r.state, r.asset)?.id).toBe(upper);
expect(currentShapeList()).toBeNull();
expect(r.texts()).toContain('Nothing on this layer yet – the Pen draws onto it.');
pickVectorLayer(r.state, r.asset.id, base);
r.build();
expect(currentShapeList()!.shownIds).toEqual(['c', 'b', 'a']);
});
it('follows the selected shape onto its layer, and remembers it once the shape is let go', () => {
const r = rig();
const base = r.asset.layers[0]!.id;
r.addLayer();
r.state.select(shapeSelection(r.asset.id, ['b']));
expect(activeLayerOf(r.state, r.asset)?.id).toBe(base);
r.state.select({ kind: 'asset', assetId: r.asset.id });
expect(activeLayerOf(r.state, r.asset)?.id).toBe(base);
});
it('lets go of the shapes of another layer when a layer is picked', () => {
const r = rig();
const upper = r.addLayer();
r.state.select(shapeSelection(r.asset.id, ['b']));
pickVectorLayer(r.state, r.asset.id, upper);
expect(r.state.selection).toEqual({ kind: 'asset', assetId: r.asset.id });
expect(activeLayerOf(r.state, r.asset)?.id).toBe(upper);
});
it('rebuilds the rows for another layer, a group and a hidden layer', () => {
const r = rig();
const base = r.asset.layers[0]!.id;
pickVectorLayer(r.state, r.asset.id, base);
const flat = shapeListSignature(r.asset);
r.group('a', 'b');
const grouped = shapeListSignature(r.asset);
expect(grouped).not.toBe(flat);
r.state.execute(new SetVectorLayerHiddenCommand(r.asset.id, base, true));
const hidden = shapeListSignature(r.asset);
expect(hidden).not.toBe(grouped);
const upper = r.addLayer();
pickVectorLayer(r.state, r.asset.id, base);
const onBase = shapeListSignature(r.asset);
pickVectorLayer(r.state, r.asset.id, upper);
expect(shapeListSignature(r.asset)).not.toBe(onBase);
expect(shapeSignature(r.state.project, r.asset.id, 'a', ['a'])).toContain(`@${base}[`);
});
it('shows a group over its shapes and selects all of them from its row', () => {
const r = rig();
const g = r.group('a', 'b');
r.state.select(shapeSelection(r.asset.id, ['a']));
r.build();
expect(currentShapeList()!.shownIds).toEqual(['c', g, 'b', 'a']);
pickShapeRow(r.state, r.asset.id, g, false);
expect(r.state.selectedShapeIds).toEqual(['a', 'b']);
});
it('adds a group to the selection with Ctrl and takes it out again as a whole', () => {
const r = rig();
const g = r.group('a', 'b');
r.state.select(shapeSelection(r.asset.id, ['c']));
pickShapeRow(r.state, r.asset.id, g, true);
expect(r.state.selectedShapeIds).toEqual(['c', 'a', 'b']);
pickShapeRow(r.state, r.asset.id, g, true);
expect(r.state.selectedShapeIds).toEqual(['c']);
pickShapeRow(r.state, r.asset.id, 'c', true);
expect(r.state.selection).toEqual({ kind: 'asset', assetId: r.asset.id });
});
it('puts a shape added from the inspector onto the layer worked on', () => {
const r = rig();
const base = r.asset.layers[0]!.id;
r.addLayer();
pickVectorLayer(r.state, r.asset.id, base);
r.build();
r.button('Add shape').onClick!();
expect(r.asset.layers[0]!.items).toHaveLength(4);
expect(r.asset.layers[1]!.items).toHaveLength(0);
});
});
describe('grouping from the inspector', () => {
it('groups the selection, which then reads as the group, and ungroups it again', () => {
const r = rig();
r.state.select(shapeSelection(r.asset.id, ['a', 'c']));
r.build();
expect(r.button('Group').enabled).toBe(true);
expect(r.button('Ungroup').enabled).toBe(false);
r.button('Group').onClick!();
const made = r.asset.layers[0]!.items[1]!;
expect(r.tree()).toEqual([`b ${made.id}[a c]`]);
expect(r.state.selectedShapeIds).toEqual(['a', 'c']);
r.build();
expect(r.button('Ungroup').enabled).toBe(true);
r.button('Ungroup').onClick!();
expect(r.tree()).toEqual(['b a c']);
r.state.undo();
r.state.undo();
expect(r.tree()).toEqual(['a b c']);
});
it('ungroups the group the primary shape stands in when no group is picked as a whole', () => {
const r = rig();
r.group('a', 'b');
r.state.select(shapeSelection(r.asset.id, ['a']));
r.build();
expect(r.button('Ungroup').enabled).toBe(true);
r.button('Ungroup').onClick!();
expect(r.tree()).toEqual(['a b c']);
});
it('moves a whole picked group with the order buttons, and a lone shape inside its group', () => {
const r = rig();
const g = r.group('a', 'b');
r.state.select(shapeSelection(r.asset.id, ['a', 'b']));
r.build();
r.button('To front').onClick!();
expect(r.tree()).toEqual([`c ${g}[a b]`]);
r.state.select(shapeSelection(r.asset.id, ['a']));
r.build();
expect(r.button('Backward').enabled).toBe(false);
r.button('Forward').onClick!();
expect(r.tree()).toEqual([`c ${g}[b a]`]);
});
it('switches Group off across two layers', () => {
const r = rig();
const upper = r.addLayer();
moveShapesToLayer(r.state, r.asset.id, ['c'], upper);
r.state.select(shapeSelection(r.asset.id, ['a', 'c']));
r.build();
expect(r.button('Group').enabled).toBe(false);
});
it('offers the Layer row from two layers on and moves a picked group as a whole', () => {
const r = rig();
const g = r.group('a', 'b');
r.state.select(shapeSelection(r.asset.id, ['a', 'b']));
r.build();
expect(r.host.fields.has('shapeLayer')).toBe(false);
const upper = r.addLayer();
r.build();
expect(r.host.fields.has('shapeLayer')).toBe(true);
moveShapesToLayer(r.state, r.asset.id, ['a', 'b'], upper);
expect(r.tree()).toEqual(['c', `${g}[a b]`]);
// The selection went along, so the layer worked on is now the upper one.
expect(activeLayerOf(r.state, r.asset)?.id).toBe(upper);
// Already there: no second step, so one undo takes the move back.
moveShapesToLayer(r.state, r.asset.id, ['a', 'b'], upper);
r.state.undo();
expect(r.tree()).toEqual([`${g}[a b] c`, '']);
});
});
/** Presses run on one clock, far enough apart that no two join into a double-click. */
let clock = 0;
function pressAt(global: Vec2, held: 'ctrl' | 'shift' | null = null) {
clock += 1000;
return {
timeStamp: clock,
button: 0,
global,
altKey: false,
ctrlKey: held === 'ctrl',
metaKey: false,
shiftKey: held === 'shift',
stopPropagation: () => undefined,
} as unknown as FederatedPointerEvent;
}
interface Pressable {
shiftPresses(asset: VectorArtAsset, local: Vec2): boolean;
press(asset: VectorArtAsset, e: FederatedPointerEvent, local: Vec2): void;
move(local: Vec2): void;
release(): void;
}
function tab() {
const r = rig();
const canvas = new VectorArtCanvas(r.state);
canvas.show(r.asset.id);
canvas.resize(400, 400);
canvas.redraw();
const target = canvas as unknown as Pressable;
const at = (x: number, y: number) => canvas.imageToScreen(x, y);
// A Shift press only reaches the tab where it is not a pan, which the tab says itself.
const click = (point: Vec2, held: 'ctrl' | 'shift' | null = null) => {
if (held === 'shift' && !target.shiftPresses(r.asset, point)) return;
target.press(r.asset, pressAt(point, held), point);
target.release();
};
const drag = (from: Vec2, to: Vec2) => {
target.press(r.asset, pressAt(from), from);
target.move(to);
target.release();
};
const corner = (id: string) => allShapes(r.asset).find((s) => s.id === id)!.rings[0]!.points[0];
return { ...r, canvas, at, click, drag, corner };
}
describe('the tab', () => {
it('selects the whole group on a press on one of its shapes, the pressed one first', () => {
const t = tab();
t.group('a', 'b');
t.click(t.at(50, 10));
expect(t.state.selection).toMatchObject({ shapeId: 'b', shapeIds: ['a', 'b'] });
t.click(t.at(90, 10));
expect(t.state.selection).toMatchObject({ shapeId: 'c', shapeIds: ['c'] });
});
it('selects the outermost group of a shape that stands two groups deep', () => {
const t = tab();
const inner = t.group('a');
t.group(inner, 'b');
t.click(t.at(10, 10));
expect(t.state.selection).toMatchObject({ shapeId: 'a', shapeIds: ['a', 'b'] });
});
it('moves a group by dragging one of its shapes, as one undo step', () => {
const t = tab();
t.group('a', 'b');
t.drag(t.at(10, 10), t.at(15, 40));
expect(t.corner('a')).toMatchObject({ x: 5, y: 30 });
expect(t.corner('b')).toMatchObject({ x: 45, y: 30 });
expect(t.corner('c')).toMatchObject({ x: 80, y: 0 });
t.state.undo();
expect(t.corner('a')).toMatchObject({ x: 0, y: 0 });
expect(t.corner('b')).toMatchObject({ x: 40, y: 0 });
});
it('carries every selected shape when one of them is dragged, grouped or not', () => {
const t = tab();
t.state.select(shapeSelection(t.asset.id, ['a', 'c']));
t.drag(t.at(90, 10), t.at(90, 30));
expect(t.state.selection).toMatchObject({ shapeId: 'c', shapeIds: ['a', 'c'] });
expect(t.corner('a')).toMatchObject({ x: 0, y: 20 });
expect(t.corner('c')).toMatchObject({ x: 80, y: 20 });
expect(t.corner('b')).toMatchObject({ x: 40, y: 0 });
});
it.each(['ctrl', 'shift'] as const)(
'adds a shape to the selection with %s held and takes it out again',
(held) => {
const t = tab();
t.click(t.at(10, 10));
t.click(t.at(90, 10), held);
expect(t.state.selectedShapeIds).toEqual(['a', 'c']);
t.click(t.at(50, 10), held);
expect(t.state.selectedShapeIds).toEqual(['a', 'c', 'b']);
t.click(t.at(90, 10), held);
expect(t.state.selectedShapeIds).toEqual(['a', 'b']);
// Nothing was moved or changed on the way.
expect(t.corner('c')).toMatchObject({ x: 80, y: 0 });
},
);
it('leaves a Shift press to the pan beside the shapes and with another tool', () => {
const t = tab();
const target = t.canvas as unknown as Pressable;
expect(target.shiftPresses(t.asset, t.at(10, 10))).toBe(true);
expect(target.shiftPresses(t.asset, t.at(10, 60))).toBe(false);
t.canvas.setTool('pen');
expect(target.shiftPresses(t.asset, t.at(10, 10))).toBe(false);
});
it('leaves the selection as it is on a click that moves nothing', () => {
const t = tab();
t.group('a', 'b');
t.click(t.at(10, 10));
t.click(t.at(50, 10));
expect(t.state.selection).toMatchObject({ shapeId: 'b', shapeIds: ['a', 'b'] });
expect(t.corner('a')).toMatchObject({ x: 0, y: 0 });
// No step was made: the last one is still the grouping.
t.state.undo();
expect(t.tree()).toEqual(['a b c']);
});
it('groups and ungroups the selection, and does nothing while only the drawing is selected', () => {
const t = tab();
t.state.select(shapeSelection(t.asset.id, ['a', 'b']));
expect(groupShapes(t.state, t.asset.id)).toBe(true);
expect(t.tree()[0]).toMatch(/^\S+\[a b\] c$/);
expect(ungroupShapes(t.state, t.asset.id)).toBe(true);
expect(t.tree()).toEqual(['a b c']);
expect(ungroupShapes(t.state, t.asset.id)).toBe(false);
t.state.select({ kind: 'asset', assetId: t.asset.id });
expect(groupShapes(t.state, t.asset.id)).toBe(false);
});
it('does not let a shape on a hidden layer be pressed', () => {
const t = tab();
const base = t.asset.layers[0]!.id;
t.state.execute(new SetVectorLayerHiddenCommand(t.asset.id, base, true));
t.click(t.at(10, 10));
expect(t.state.selection).toEqual({ kind: 'asset', assetId: t.asset.id });
});
it('draws with the pen onto the layer worked on, and nothing onto a hidden one', () => {
const t = tab();
const base = t.asset.layers[0]!.id;
const upper = t.addLayer();
pickVectorLayer(t.state, t.asset.id, base);
t.canvas.setTool('pen');
t.click(t.at(150, 150));
expect(t.asset.layers[0]!.items).toHaveLength(4);
expect(t.asset.layers[1]!.items).toHaveLength(0);
t.canvas.onKeyDown({ key: 'Escape' } as KeyboardEvent);
t.state.execute(new SetVectorLayerHiddenCommand(t.asset.id, upper, true));
pickVectorLayer(t.state, t.asset.id, upper);
t.click(t.at(170, 170));
expect(allShapes(t.asset)).toHaveLength(4);
});
});