Files
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

427 lines
17 KiB
TypeScript
Raw Permalink 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.
import { afterEach, describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import {
AddVectorShapeCommand,
DuplicateVectorShapeCommand,
OrderVectorShapeCommand,
RemoveVectorShapeCommand,
RenameVectorShapeCommand,
SetVectorShapeRingsCommand,
SetVectorShapeStyleCommand,
} from '../../src/core/commands/vectorArtCommands';
import type { VectorArtAsset, VectorRing } from '../../src/core/model/Asset';
import { isPlaceableType } from '../../src/core/model/AssetTypes';
import { generatedPictureOf, registerPictureSource } from '../../src/core/model/generated';
import { findAsset, type Project } from '../../src/core/model/Project';
import { resolvePicture } from '../../src/core/model/texture';
import {
handleAt,
isDrawableRing,
SHAPE_FILL_DEFAULT,
shapeExtent,
VECTOR_CANVAS_DEFAULT,
VECTOR_CANVAS_MAX,
VECTOR_HEIGHT_KEY,
VECTOR_WIDTH_KEY,
vectorArtPicture,
vectorArtRevision,
vectorCanvasSize,
} from '../../src/core/model/vectorart';
import { allShapes, shapeOrderTarget } from '../../src/core/model/vectorTree';
import { makeProject, roundTrip } from '../helpers';
/** A square, and the same square with one point bent into a curve. */
function square(): VectorRing {
return {
closed: true,
points: [
{ x: 10, y: 10 },
{ x: 90, y: 10 },
{ x: 90, y: 90 },
{ x: 10, y: 90 },
],
};
}
function drawing() {
const fixture = makeProject();
const create = new CreateAssetCommand('vectorart', 'Logo');
fixture.stack.execute(create);
const assetId = create.asset.id;
const art = (project: Project = fixture.project): VectorArtAsset =>
findAsset(project, assetId) as VectorArtAsset;
return { ...fixture, assetId, art };
}
describe('the vector art asset', () => {
it('is a picture, not something placed in a scene', () => {
expect(isPlaceableType('vectorart')).toBe(false);
});
it('starts empty on a canvas of its own', () => {
const f = drawing();
expect(allShapes(f.art())).toEqual([]);
expect(vectorCanvasSize(f.art().props)).toEqual({
width: VECTOR_CANVAS_DEFAULT,
height: VECTOR_CANVAS_DEFAULT,
});
});
it('reads a canvas that is missing, broken or absurd as one it can draw on', () => {
expect(vectorCanvasSize({})).toEqual({ width: 256, height: 256 });
expect(vectorCanvasSize({ [VECTOR_WIDTH_KEY]: 0, [VECTOR_HEIGHT_KEY]: -40 })).toEqual({
width: 256,
height: 256,
});
expect(vectorCanvasSize({ [VECTOR_WIDTH_KEY]: 99999, [VECTOR_HEIGHT_KEY]: 64.7 })).toEqual({
width: VECTOR_CANVAS_MAX,
height: 64,
});
});
});
describe('what is worth drawing', () => {
it('needs three points closed and two open', () => {
expect(isDrawableRing({ closed: true, points: square().points.slice(0, 2) })).toBe(false);
expect(isDrawableRing({ closed: true, points: square().points.slice(0, 3) })).toBe(true);
expect(isDrawableRing({ closed: false, points: square().points.slice(0, 2) })).toBe(true);
});
it('measures a shape including the handles pulled off its points', () => {
const shape = {
id: 's',
name: 'S',
rings: [
{
closed: true,
points: [
{ x: 10, y: 10, out: { x: -30, y: 5 } },
{ x: 90, y: 90 },
],
},
],
fill: 0,
opacity: 1,
};
expect(handleAt(shape.rings[0]!.points[0]!, 'out')).toEqual({ x: -20, y: 15 });
expect(handleAt(shape.rings[0]!.points[1]!, 'in')).toBeNull();
expect(shapeExtent(shape)).toEqual({ x0: -20, y0: 10, x1: 90, y1: 90 });
expect(shapeExtent({ ...shape, rings: [] })).toBeNull();
});
});
describe('the revision', () => {
it('changes with every pixel and with nothing else', () => {
const f = drawing();
const add = new AddVectorShapeCommand(f.assetId, [square()]);
f.stack.execute(add);
const first = vectorArtRevision(f.art());
f.stack.execute(new RenameVectorShapeCommand(f.assetId, add.shapeId, 'Body'));
expect(vectorArtRevision(f.art())).toBe(first);
// A point's type only says what the next drag does to its handles (#195).
const typed = square();
typed.points[0] = { ...typed.points[0]!, mode: 'smooth' };
f.stack.execute(new SetVectorShapeRingsCommand(f.assetId, add.shapeId, [typed]));
expect(vectorArtRevision(f.art())).toBe(first);
f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { fill: 0xff0000 }));
const coloured = vectorArtRevision(f.art());
expect(coloured).not.toBe(first);
const moved = square();
moved.points[0] = { x: 11, y: 10 };
f.stack.execute(new SetVectorShapeRingsCommand(f.assetId, add.shapeId, [moved]));
expect(vectorArtRevision(f.art())).not.toBe(coloured);
});
it('changes when the canvas is resized, because the picture is a different one', () => {
const f = drawing();
f.stack.execute(new AddVectorShapeCommand(f.assetId, [square()]));
const before = vectorArtRevision(f.art());
f.stack.execute(new SetAssetPropertyCommand(f.assetId, VECTOR_WIDTH_KEY, 512));
expect(vectorArtRevision(f.art())).not.toBe(before);
});
it('is the same for the same drawing, however the object was built', () => {
const asset = (ring: VectorRing): VectorArtAsset => ({
id: 'a',
type: 'vectorart',
name: 'A',
props: {},
layers: [
{ id: 'l', name: 'Layer 1', items: [{ id: 's', name: 'S', rings: [ring], fill: 1, opacity: 1 }] },
],
});
// The same ring with its two keys set the other way round – what a project file gives back.
const built = { closed: true, points: square().points };
const loaded = { points: square().points, closed: true } as VectorRing;
expect(vectorArtRevision(asset(loaded))).toBe(vectorArtRevision(asset(built)));
});
it('survives a save and load: the same drawing is the same picture', () => {
const f = drawing();
f.stack.execute(new AddVectorShapeCommand(f.assetId, [square()]));
expect(vectorArtRevision(f.art(roundTrip(f.project)))).toBe(vectorArtRevision(f.art()));
});
});
describe('the way into the picture path', () => {
let unregister: (() => void) | null = null;
afterEach(() => {
unregister?.();
unregister = null;
});
it('hands over the canvas and the revision, and only for vector art', () => {
const f = drawing();
expect(vectorArtPicture(f.art())).toEqual({
width: VECTOR_CANVAS_DEFAULT,
height: VECTOR_CANVAS_DEFAULT,
revision: vectorArtRevision(f.art()),
});
const other = new CreateAssetCommand('sprite', 'Box');
f.stack.execute(other);
expect(vectorArtPicture(other.asset)).toBeNull();
});
it('resolves to a generated picture of the canvas size once it is registered (#185)', () => {
const f = drawing();
f.stack.execute(new SetAssetPropertyCommand(f.assetId, VECTOR_HEIGHT_KEY, 128));
expect(generatedPictureOf(f.art())).toBeNull();
unregister = registerPictureSource('vectorart', vectorArtPicture);
const ref = resolvePicture(f.project, f.assetId);
expect(ref?.file).toBe(`gen:${f.assetId}@${vectorArtRevision(f.art())}`);
expect(ref?.sourceWidth).toBe(VECTOR_CANVAS_DEFAULT);
expect(ref?.sourceHeight).toBe(128);
});
});
describe('editing the shapes', () => {
it('numbers new shapes and takes them back out again', () => {
const f = drawing();
const first = new AddVectorShapeCommand(f.assetId);
const second = new AddVectorShapeCommand(f.assetId, [square()], 0x112233);
f.stack.execute(first);
f.stack.execute(second);
expect(allShapes(f.art()).map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']);
expect(allShapes(f.art())[1]).toMatchObject({ fill: 0x112233, opacity: 1 });
expect(allShapes(f.art())[0]!.fill).toBe(SHAPE_FILL_DEFAULT);
f.stack.undo();
expect(allShapes(f.art()).map((s) => s.name)).toEqual(['Shape 1']);
f.stack.redo();
expect(allShapes(f.art()).map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']);
});
it('puts a removed shape back where it stood, unlike a tilemap keeping its last layer', () => {
const f = drawing();
const ids = [0, 1, 2].map(() => {
const add = new AddVectorShapeCommand(f.assetId, [square()]);
f.stack.execute(add);
return add.shapeId;
});
f.stack.execute(new RemoveVectorShapeCommand(f.assetId, ids[1]!));
expect(allShapes(f.art()).map((s) => s.id)).toEqual([ids[0], ids[2]]);
f.stack.undo();
expect(allShapes(f.art()).map((s) => s.id)).toEqual(ids);
// The last one may go: an empty drawing is a drawing.
for (const id of ids) f.stack.execute(new RemoveVectorShapeCommand(f.assetId, id));
expect(allShapes(f.art())).toEqual([]);
});
it('duplicates a shape right above it, named as a copy, with outlines of its own', () => {
const f = drawing();
const add = new AddVectorShapeCommand(f.assetId, [square()], 0x112233);
const top = new AddVectorShapeCommand(f.assetId, [square()]);
f.stack.execute(add);
f.stack.execute(top);
f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { opacity: 0.5 }));
const copy = new DuplicateVectorShapeCommand(f.assetId, add.shapeId);
f.stack.execute(copy);
f.stack.execute(new DuplicateVectorShapeCommand(f.assetId, add.shapeId));
const shapes = allShapes(f.art());
expect(shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 1 copy 2', 'Shape 1 copy', 'Shape 2']);
expect(shapes[2]).toMatchObject({ id: copy.shapeId, fill: 0x112233, opacity: 0.5 });
expect(shapes[2]!.rings).toEqual(shapes[0]!.rings);
// Its own outline: a point dragged on the copy leaves the original where it is.
expect(shapes[2]!.rings[0]).not.toBe(shapes[0]!.rings[0]);
f.stack.undo();
f.stack.undo();
expect(allShapes(f.art()).map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']);
expect(() => f.stack.execute(new DuplicateVectorShapeCommand(f.assetId, 'nobody'))).toThrow(/not found/);
});
it('keeps the drawing order apart from the list order in no way at all', () => {
const f = drawing();
const ids = ['a', 'b', 'c'].map(() => {
const add = new AddVectorShapeCommand(f.assetId, [square()]);
f.stack.execute(add);
return add.shapeId;
});
// Last is on top, so "forward" is one step towards the end and "front" is the end.
expect(shapeOrderTarget(allShapes(f.art()), ids[0]!, 'forward')).toBe(1);
expect(shapeOrderTarget(allShapes(f.art()), ids[0]!, 'front')).toBe(2);
expect(shapeOrderTarget(allShapes(f.art()), ids[2]!, 'backward')).toBe(1);
expect(shapeOrderTarget(allShapes(f.art()), ids[1]!, 'back')).toBe(0);
// Already there, and not there at all.
expect(shapeOrderTarget(allShapes(f.art()), ids[2]!, 'front')).toBe(-1);
expect(shapeOrderTarget(allShapes(f.art()), ids[0]!, 'back')).toBe(-1);
expect(shapeOrderTarget(allShapes(f.art()), 'nobody', 'front')).toBe(-1);
f.stack.execute(new OrderVectorShapeCommand(f.assetId, ids[0]!, 'front'));
expect(allShapes(f.art()).map((s) => s.id)).toEqual([ids[1], ids[2], ids[0]]);
f.stack.undo();
expect(allShapes(f.art()).map((s) => s.id)).toEqual(ids);
});
it('copies the outline it is handed, so a later drag does not reach into the undo step', () => {
const f = drawing();
const add = new AddVectorShapeCommand(f.assetId, [square()]);
f.stack.execute(add);
const rings = [square()];
rings[0]!.points[0] = { x: 0, y: 0, out: { x: 5, y: 5 } };
const edit = new SetVectorShapeRingsCommand(f.assetId, add.shapeId, rings);
expect(SetVectorShapeRingsCommand.changes(allShapes(f.art())[0]!, rings)).toBe(true);
f.stack.execute(edit);
rings[0]!.points[1] = { x: -999, y: -999 };
expect(allShapes(f.art())[0]!.rings[0]!.points[0]).toEqual({ x: 0, y: 0, out: { x: 5, y: 5 } });
expect(allShapes(f.art())[0]!.rings[0]!.points[1]).toEqual({ x: 90, y: 10 });
f.stack.undo();
expect(allShapes(f.art())[0]!.rings[0]!.points[0]).toEqual({ x: 10, y: 10 });
expect(SetVectorShapeRingsCommand.changes(allShapes(f.art())[0]!, [square()])).toBe(false);
});
it('writes fill, opacity and outline one field at a time and takes each back', () => {
const f = drawing();
const add = new AddVectorShapeCommand(f.assetId, [square()]);
f.stack.execute(add);
f.stack.execute(
new SetVectorShapeStyleCommand(f.assetId, add.shapeId, {
fill: 0x00ff00,
opacity: 4,
stroke: { color: 0, width: 2 },
}),
);
// An opacity outside 0…1 is clamped, not refused: a slider may overshoot.
expect(allShapes(f.art())[0]).toMatchObject({ fill: 0x00ff00, opacity: 1 });
expect(allShapes(f.art())[0]!.stroke).toEqual({ color: 0, width: 2 });
f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { stroke: null }));
expect(allShapes(f.art())[0]!.stroke).toBeUndefined();
f.stack.undo();
expect(allShapes(f.art())[0]!.stroke).toEqual({ color: 0, width: 2 });
f.stack.undo();
expect(allShapes(f.art())[0]).toMatchObject({ fill: SHAPE_FILL_DEFAULT, opacity: 1 });
expect(allShapes(f.art())[0]!.stroke).toBeUndefined();
});
it('refuses an outline of no width and a shape of no name', () => {
const f = drawing();
const add = new AddVectorShapeCommand(f.assetId, [square()]);
f.stack.execute(add);
expect(() =>
f.stack.execute(
new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { stroke: { color: 0, width: 0 } }),
),
).toThrow(/wider than nothing/);
expect(() => f.stack.execute(new RenameVectorShapeCommand(f.assetId, add.shapeId, ' '))).toThrow(
/needs a name/,
);
expect(() => f.stack.execute(new RenameVectorShapeCommand(f.assetId, 'nobody', 'X'))).toThrow(
/not found/,
);
});
});
describe('what a project file may say', () => {
it('keeps a whole drawing across save and load', () => {
const f = drawing();
const add = new AddVectorShapeCommand(f.assetId, [
{
closed: true,
points: [
{ x: 10, y: 10, out: { x: 4, y: 0 } },
{ x: 90, y: 10, in: { x: -3, y: 0 }, out: { x: 6, y: 0 }, mode: 'smooth' },
{ x: 50, y: 80, in: { x: -2, y: 1 }, out: { x: 2, y: -1 }, mode: 'symmetric' },
],
},
{
closed: false,
points: [
{ x: 20, y: 20 },
{ x: 30, y: 30 },
],
},
]);
f.stack.execute(add);
f.stack.execute(
new SetVectorShapeStyleCommand(f.assetId, add.shapeId, {
fill: 0x334455,
opacity: 0.5,
stroke: { color: 0xffffff, width: 3 },
}),
);
expect(allShapes(f.art(roundTrip(f.project)))).toEqual(allShapes(f.art()));
});
it('drops what cannot be drawn from and keeps what someone is still drawing', () => {
const f = drawing();
const art = f.art() as unknown as Record<string, unknown>;
delete art.layers;
art.shapes = [
{
id: 'one',
name: 'One',
rings: [
{
closed: true,
points: [
{ x: 10, y: 10, in: { x: 'left', y: 2 }, out: { x: 1, y: 2 }, mode: 'wobbly' },
{ x: 'far', y: 10 },
{ x: 30, y: 30 },
{ x: 50 },
],
},
{ points: [{ x: 0, y: 0 }] },
],
fill: 0x123456,
opacity: 7,
stroke: { color: 0xffffff, width: 0 },
},
// A doubled id would make one reference mean two shapes, as a doubled layer id does.
{ id: 'one', name: 'Twin', rings: [] },
{ name: 'Nameless', rings: [] },
'not a shape',
];
const loaded = f.art(roundTrip(f.project));
expect(allShapes(loaded)).toHaveLength(1);
const shape = allShapes(loaded)[0]!;
// The broken handle falls away and leaves a corner, and so does a point type nobody knows (#195);
// the broken points fall away entirely.
expect(shape.rings[0]!.points).toEqual([
{ x: 10, y: 10, out: { x: 1, y: 2 } },
{ x: 30, y: 30 },
]);
// A ring of one point is kept – it is an outline someone is still drawing – and closed by default.
expect(shape.rings[1]).toEqual({ closed: true, points: [{ x: 0, y: 0 }] });
expect(shape).toMatchObject({ fill: 0x123456, opacity: 1 });
// An outline of no width is no outline.
expect(shape.stroke).toBeUndefined();
});
it('falls back to a fill and a name where the file says nothing usable', () => {
const f = drawing();
const art = f.art() as unknown as Record<string, unknown>;
delete art.layers;
art.shapes = [{ id: 'one', rings: [{ closed: false, points: [] }], fill: 'blue' }];
const shape = allShapes(f.art(roundTrip(f.project)))[0]!;
expect(shape).toMatchObject({ name: 'Shape', fill: SHAPE_FILL_DEFAULT, opacity: 1 });
expect(shape.rings).toEqual([{ closed: false, points: [] }]);
});
});