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>
311 lines
13 KiB
TypeScript
311 lines
13 KiB
TypeScript
/**
|
|
* Gradients and picture fills of vector art (#186): the rules of `vectorPaint`, what the drawing
|
|
* commands make of a paint, how a project file brings one back, and the commands that write it.
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import { CreateAssetCommand, CreateTextureCommand } from '../../src/core/commands/assetCommands';
|
|
import { CommandError } from '../../src/core/commands/Command';
|
|
import {
|
|
AddVectorShapeCommand,
|
|
SetVectorShapeRingsCommand,
|
|
SetVectorShapeStyleCommand,
|
|
} from '../../src/core/commands/vectorArtCommands';
|
|
import type {
|
|
TextureAsset,
|
|
VectorArtAsset,
|
|
VectorGradient,
|
|
VectorRing,
|
|
VectorShape,
|
|
} from '../../src/core/model/Asset';
|
|
import { findAsset, type Project } from '../../src/core/model/Project';
|
|
import { vectorArtRevision } from '../../src/core/model/vectorart';
|
|
import {
|
|
colorAt,
|
|
fillPaintFor,
|
|
paintExtent,
|
|
paintMoved,
|
|
sortedStops,
|
|
strokePaintFor,
|
|
textureFillRef,
|
|
withoutStop,
|
|
withStopAdded,
|
|
withStopChanged,
|
|
} from '../../src/core/model/vectorPaint';
|
|
import { shapeDrawing } from '../../src/core/model/vectorPath';
|
|
import { makeProject, roundTrip } from '../helpers';
|
|
import { allShapes } from '../../src/core/model/vectorTree';
|
|
|
|
const FILE = `${'c'.repeat(64)}.png`;
|
|
|
|
const SQUARE: VectorRing = {
|
|
closed: true,
|
|
points: [
|
|
{ x: 20, y: 40 },
|
|
{ x: 120, y: 40 },
|
|
{ x: 120, y: 90 },
|
|
{ x: 20, y: 90 },
|
|
],
|
|
};
|
|
|
|
function shape(over: Partial<VectorShape> = {}): VectorShape {
|
|
return { id: 's', name: 'Shape 1', rings: [SQUARE], fill: 0x336699, opacity: 1, ...over };
|
|
}
|
|
|
|
function gradient(over: Partial<VectorGradient> = {}): VectorGradient {
|
|
return {
|
|
type: 'linear',
|
|
from: { x: 0, y: 0 },
|
|
to: { x: 100, y: 0 },
|
|
stops: [
|
|
{ offset: 0, color: 0x000000, alpha: 1 },
|
|
{ offset: 1, color: 0xffffff, alpha: 0 },
|
|
],
|
|
...over,
|
|
};
|
|
}
|
|
|
|
/** A project with a drawing holding one square, and a 64x32 texture with one 16x16 frame. */
|
|
function withTexture() {
|
|
const fixture = makeProject();
|
|
const art = new CreateAssetCommand('vectorart', 'Logo');
|
|
fixture.stack.execute(art);
|
|
const texture = new CreateTextureCommand('Bricks', { file: FILE, width: 64, height: 32 });
|
|
fixture.stack.execute(texture);
|
|
const sheet = findAsset(fixture.project, texture.asset.id) as TextureAsset;
|
|
sheet.frames.push({ id: 'f1', name: 'Brick', x: 16, y: 0, w: 16, h: 16 });
|
|
const add = new AddVectorShapeCommand(art.asset.id, [SQUARE]);
|
|
fixture.stack.execute(add);
|
|
const drawing = (project: Project = fixture.project) => findAsset(project, art.asset.id) as VectorArtAsset;
|
|
return { ...fixture, assetId: art.asset.id, shapeId: add.shapeId, textureId: texture.asset.id, drawing };
|
|
}
|
|
|
|
describe('switching a fill to a paint', () => {
|
|
const extent = paintExtent({ x0: 20, y0: 40, x1: 120, y1: 90 }, { width: 256, height: 256 });
|
|
|
|
it('lays a linear gradient across the middle of the shape, from its colour to white', () => {
|
|
expect(fillPaintFor(shape(), 'linear', extent)).toEqual({
|
|
type: 'linear',
|
|
from: { x: 20, y: 65 },
|
|
to: { x: 120, y: 65 },
|
|
stops: [
|
|
{ offset: 0, color: 0x336699, alpha: 1 },
|
|
{ offset: 1, color: 0xffffff, alpha: 1 },
|
|
],
|
|
});
|
|
});
|
|
|
|
it('centres a radial gradient and lets it reach the farther side', () => {
|
|
const paint = fillPaintFor(shape(), 'radial', extent);
|
|
expect(paint).toMatchObject({ type: 'radial', from: { x: 70, y: 65 }, to: { x: 120, y: 65 } });
|
|
});
|
|
|
|
it('keeps the colours when a gradient only changes its kind, and lays it out anew', () => {
|
|
const linear = gradient({ stops: [...gradient().stops, { offset: 0.3, color: 0xff0000, alpha: 1 }] });
|
|
const radial = fillPaintFor(shape({ paint: linear }), 'radial', extent) as VectorGradient;
|
|
expect(radial.stops).toEqual(linear.stops);
|
|
expect(radial.from).toEqual({ x: 70, y: 65 });
|
|
});
|
|
|
|
it('spans a picture over the shape once, keeping the picture it had', () => {
|
|
const tiled = shape({
|
|
paint: { type: 'texture', from: { x: 0, y: 0 }, to: { x: 8, y: 8 }, texture: 't' },
|
|
});
|
|
expect(fillPaintFor(tiled, 'texture', extent)).toEqual({
|
|
type: 'texture',
|
|
from: { x: 20, y: 40 },
|
|
to: { x: 120, y: 90 },
|
|
texture: 't',
|
|
});
|
|
expect(fillPaintFor(shape(), 'solid', extent)).toBeNull();
|
|
});
|
|
|
|
it('starts an outline gradient from the line colour, and a shape without one gets none', () => {
|
|
const outlined = shape({ stroke: { color: 0x00ff00, width: 2 } });
|
|
expect((strokePaintFor(outlined, 'linear', extent) as VectorGradient).stops[0]?.color).toBe(0x00ff00);
|
|
expect(strokePaintFor(shape(), 'linear', extent)).toBeNull();
|
|
});
|
|
|
|
it('never lays a paint over nothing: a line has a height, an empty shape the canvas', () => {
|
|
expect(paintExtent({ x0: 5, y0: 10, x1: 50, y1: 10 }, { width: 64, height: 64 }).y1).toBe(11);
|
|
expect(paintExtent(null, { width: 64, height: 32 })).toEqual({ x0: 0, y0: 0, x1: 64, y1: 32 });
|
|
});
|
|
});
|
|
|
|
describe('colour stops', () => {
|
|
it('adds a stop in the colour the gradient already has there, so nothing changes yet', () => {
|
|
const added = withStopAdded(gradient(), 0.25);
|
|
expect(added.stops).toHaveLength(3);
|
|
expect(added.stops[2]).toEqual({ offset: 0.25, color: 0x404040, alpha: 0.75 });
|
|
});
|
|
|
|
it('reads the colour between stops in drawing order, whatever order the list is in', () => {
|
|
const shuffled = [
|
|
{ offset: 1, color: 0x0000ff, alpha: 1 },
|
|
{ offset: 0, color: 0xff0000, alpha: 1 },
|
|
];
|
|
expect(colorAt(shuffled, 0.5).color).toBe(0x800080);
|
|
expect(colorAt(shuffled, -3).color).toBe(0xff0000);
|
|
expect(colorAt(shuffled, 7).color).toBe(0x0000ff);
|
|
});
|
|
|
|
it('keeps a dragged stop where it is in the list, and sorts only for drawing', () => {
|
|
const moved = withStopChanged(gradient(), 0, { offset: 0.9 })!;
|
|
expect(moved.stops[0]?.offset).toBe(0.9);
|
|
expect(sortedStops(moved.stops).map((s) => s.offset)).toEqual([0.9, 1]);
|
|
expect(withStopChanged(gradient(), 0, { offset: 4, alpha: -1 })!.stops[0]).toMatchObject({
|
|
offset: 1,
|
|
alpha: 0,
|
|
});
|
|
expect(withStopChanged(gradient(), 5, { color: 1 })).toBeNull();
|
|
});
|
|
|
|
it('takes a stop out but never the second-last one', () => {
|
|
const three = withStopAdded(gradient(), 0.5);
|
|
expect(withoutStop(three, 0)?.stops.map((s) => s.offset)).toEqual([1, 0.5]);
|
|
expect(withoutStop(gradient(), 0)).toBeNull();
|
|
});
|
|
|
|
it('moves both ends of a paint with its shape', () => {
|
|
expect(paintMoved(gradient(), 5, -2)).toMatchObject({ from: { x: 5, y: -2 }, to: { x: 105, y: -2 } });
|
|
});
|
|
});
|
|
|
|
describe('drawing a paint', () => {
|
|
it('hands the stops out sorted and keeps the solid colour next to it', () => {
|
|
const drawn = shapeDrawing(shape({ paint: gradient({ stops: [...gradient().stops].reverse() }) }))!;
|
|
expect(drawn.color).toBe(0x336699);
|
|
expect(drawn.paint).toMatchObject({ type: 'linear', stops: [{ offset: 0 }, { offset: 1 }] });
|
|
expect(drawn.strokePaint).toBeNull();
|
|
});
|
|
|
|
it('falls back to the colour for a gradient that has no length or one stop', () => {
|
|
expect(shapeDrawing(shape({ paint: gradient({ to: { x: 0, y: 0 } }) }))!.paint).toBeNull();
|
|
const single = gradient({ stops: [{ offset: 0, color: 0, alpha: 1 }] });
|
|
expect(shapeDrawing(shape({ paint: single }))!.paint).toBeNull();
|
|
});
|
|
|
|
it('paints the outline with its own gradient', () => {
|
|
const drawn = shapeDrawing(
|
|
shape({ stroke: { color: 0, width: 3, paint: gradient({ type: 'radial' }) } }),
|
|
)!;
|
|
expect(drawn.strokePaint?.type).toBe('radial');
|
|
});
|
|
|
|
it('resolves a picture fill to the texture or frame it names, never to a drawn picture', () => {
|
|
const f = withTexture();
|
|
const whole = textureFillRef(f.project, f.textureId);
|
|
expect(whole).toMatchObject({ file: FILE, rect: null });
|
|
expect(textureFillRef(f.project, 'f1')).toMatchObject({
|
|
file: FILE,
|
|
rect: { x: 16, y: 0, w: 16, h: 16 },
|
|
});
|
|
expect(textureFillRef(f.project, f.assetId)).toBeNull();
|
|
expect(textureFillRef(f.project, 'gone')).toBeNull();
|
|
const paint = { type: 'texture' as const, from: { x: 0, y: 0 }, to: { x: 16, y: 16 }, texture: 'f1' };
|
|
expect(shapeDrawing(shape({ paint }), f.project)!.paint).toMatchObject({
|
|
type: 'texture',
|
|
ref: { file: FILE },
|
|
});
|
|
// A picture that is not there, and a tile of no width, draw the colour instead.
|
|
expect(shapeDrawing(shape({ paint: { ...paint, texture: 'gone' } }), f.project)!.paint).toBeNull();
|
|
expect(shapeDrawing(shape({ paint: { ...paint, to: { x: 0, y: 16 } } }), f.project)!.paint).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('a paint in the picture key and the project file', () => {
|
|
it('is a new drawing when a stop, an end or the picture behind a texture changes', () => {
|
|
const f = withTexture();
|
|
const shapeOf = () => allShapes(f.drawing())[0]!;
|
|
const plain = vectorArtRevision(f.drawing(), f.drawing().props, f.project);
|
|
shapeOf().paint = gradient();
|
|
const linear = vectorArtRevision(f.drawing(), f.drawing().props, f.project);
|
|
expect(linear).not.toBe(plain);
|
|
shapeOf().paint = withStopChanged(gradient(), 1, { alpha: 0.5 })!;
|
|
expect(vectorArtRevision(f.drawing(), f.drawing().props, f.project)).not.toBe(linear);
|
|
|
|
shapeOf().paint = { type: 'texture', from: { x: 0, y: 0 }, to: { x: 16, y: 16 }, texture: 'f1' };
|
|
const brick = vectorArtRevision(f.drawing(), f.drawing().props, f.project);
|
|
// The frame is moved on its sheet: same id on the shape, another picture in it.
|
|
(findAsset(f.project, f.textureId) as TextureAsset).frames[0]!.x = 32;
|
|
expect(vectorArtRevision(f.drawing(), f.drawing().props, f.project)).not.toBe(brick);
|
|
});
|
|
|
|
it('comes back from a project file as it was written', () => {
|
|
const f = withTexture();
|
|
const shapeNow = allShapes(f.drawing())[0]!;
|
|
shapeNow.paint = gradient({ type: 'radial' });
|
|
shapeNow.stroke = { color: 0xff0000, width: 2, paint: gradient() };
|
|
const back = allShapes(f.drawing(roundTrip(f.project)))[0]!;
|
|
expect(back.paint).toEqual(shapeNow.paint);
|
|
expect(back.stroke).toEqual(shapeNow.stroke);
|
|
expect(vectorArtRevision(f.drawing(roundTrip(f.project)))).toBe(vectorArtRevision(f.drawing()));
|
|
});
|
|
|
|
it('drops a paint it cannot draw and keeps the colour the shape had', () => {
|
|
const f = withTexture();
|
|
const raw = JSON.parse(JSON.stringify(f.project)) as {
|
|
assets: { type: string; layers?: { items: unknown[] }[] }[];
|
|
};
|
|
const drawing = raw.assets.find((a) => a.type === 'vectorart')!;
|
|
const written = drawing.layers![0]!.items[0] as Record<string, unknown>;
|
|
written.paint = {
|
|
type: 'linear',
|
|
from: { x: 0, y: 0 },
|
|
to: { x: 1, y: 0 },
|
|
stops: [{ offset: 0, color: 1 }],
|
|
};
|
|
written.stroke = {
|
|
color: 5,
|
|
width: 1,
|
|
paint: { type: 'texture', from: { x: 0, y: 0 }, to: { x: 1, y: 1 }, texture: 'x' },
|
|
};
|
|
const back = allShapes(f.drawing(roundTrip(raw as unknown as Project)))[0]!;
|
|
expect(back.paint).toBeUndefined();
|
|
expect(back.stroke).toEqual({ color: 5, width: 1 });
|
|
});
|
|
});
|
|
|
|
describe('writing a paint', () => {
|
|
it('sets and removes a fill paint as one undo step each', () => {
|
|
const f = withTexture();
|
|
f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, f.shapeId, { paint: gradient() }));
|
|
expect(allShapes(f.drawing())[0]!.paint).toEqual(gradient());
|
|
f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, f.shapeId, { paint: null }));
|
|
expect(allShapes(f.drawing())[0]!.paint).toBeUndefined();
|
|
f.stack.undo();
|
|
expect(allShapes(f.drawing())[0]!.paint).toEqual(gradient());
|
|
f.stack.undo();
|
|
expect(allShapes(f.drawing())[0]!.paint).toBeUndefined();
|
|
});
|
|
|
|
it('refuses a gradient of one colour, on the fill and on the outline', () => {
|
|
const f = withTexture();
|
|
const one = gradient({ stops: [{ offset: 0, color: 0, alpha: 1 }] });
|
|
expect(() =>
|
|
new SetVectorShapeStyleCommand(f.assetId, f.shapeId, { paint: one }).execute(f.project),
|
|
).toThrow(CommandError);
|
|
const stroke = { color: 0, width: 1, paint: one };
|
|
expect(() => new SetVectorShapeStyleCommand(f.assetId, f.shapeId, { stroke }).execute(f.project)).toThrow(
|
|
CommandError,
|
|
);
|
|
});
|
|
|
|
it('moves the paints along when a whole shape is dragged, and undo takes both back', () => {
|
|
const f = withTexture();
|
|
const shapeNow = allShapes(f.drawing())[0]!;
|
|
shapeNow.paint = gradient();
|
|
shapeNow.stroke = { color: 0, width: 1, paint: gradient({ type: 'radial' }) };
|
|
const rings = [{ ...SQUARE, points: SQUARE.points.map((p) => ({ x: p.x + 10, y: p.y })) }];
|
|
const paints = {
|
|
paint: paintMoved(gradient(), 10, 0),
|
|
strokePaint: paintMoved(gradient({ type: 'radial' }), 10, 0),
|
|
};
|
|
f.stack.execute(new SetVectorShapeRingsCommand(f.assetId, f.shapeId, rings, 'Move shape', paints));
|
|
expect(allShapes(f.drawing())[0]!.paint?.from).toEqual({ x: 10, y: 0 });
|
|
expect(allShapes(f.drawing())[0]!.stroke?.paint?.to).toEqual({ x: 110, y: 0 });
|
|
f.stack.undo();
|
|
expect(allShapes(f.drawing())[0]!.paint?.from).toEqual({ x: 0, y: 0 });
|
|
expect(allShapes(f.drawing())[0]!.stroke?.paint?.from).toEqual({ x: 0, y: 0 });
|
|
});
|
|
});
|