A vector shape could only be filled and outlined with one flat colour. Shading, glows and patterned surfaces had to be faked with stacks of shapes. Every shape now has a paint type for its fill and its outline. - Fill type: solid colour, linear gradient, radial gradient or texture. Line type offers the same without texture. - Colour stops are edited on a bar in the inspector, as agreed in the issue. A click on the bar adds a stop in the colour the gradient already has there, so adding never changes the picture. At least two stops remain. - The new Paint tool in the tab shows two handles for the selected shape: axis start and end, centre and radius, or the corners of one texture tile. "Handles: Fill / Outline" picks which paint is edited. - Moving a whole shape moves its gradients and tile with it. Every gesture is one undo step. - A texture fill always tiles, because Pixi turns a non-repeating pattern into a repeating one anyway. When switched on, one tile covers the whole shape. - Only uploaded textures and frames can be a fill, never a drawing. A drawing filled with itself would rasterise itself mid-raster. - The fingerprint of a drawing includes the texture and frame a fill points to, so the picture redraws when the image is swapped. Findings: - graphics.destroy(true) also destroyed the textures of the fills. With a texture fill it deleted the tile slice and the next redraw crashed on reading 'scaleMode' of null. It is now destroy(). - The gradient cache destroyed old gradients itself, which gave 20 Pixi warnings "destroyed while still bound". They are now handed to Pixi's texture GC with autoGarbageCollect switched on. The console of module 47 is empty again, the last CI log had 0 of them. Tests: - ./check.sh: 135 files, 2011 tests, exit 0 (before 134 / 1986) - npm run build: exit 0 - GM_STEPS=47, freshly seeded: ALL PASSED, 54 checks (before 44), 0 console warnings - 25 new unit tests, 10 new e2e checks in block 47i - Four counter-checks bite, originals restored and verified with cmp - Check sum 1232 in README and wiki, calculated (1222 + 10) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
376 lines
15 KiB
TypeScript
376 lines
15 KiB
TypeScript
/**
|
||
* The inspector side of vector art (#129, stage 4): the arithmetic of the shape list, which rows a
|
||
* shape shows, and what editing one of them writes into the model. The rows are driven through the
|
||
* widgets' own callbacks – that is the path a click takes, `setValue` only shows a value.
|
||
*/
|
||
import { beforeAll, describe, expect, it } from 'vitest';
|
||
import { CanvasTextMetrics } from 'pixi.js';
|
||
import {
|
||
AddVectorShapeCommand,
|
||
RemoveVectorShapeCommand,
|
||
SetVectorShapeStyleCommand,
|
||
} from '../../src/core/commands/vectorArtCommands';
|
||
import type { VectorArtAsset, VectorGradient } from '../../src/core/model/Asset';
|
||
import { buildAsset } from '../../src/core/commands/assetCommands';
|
||
import { createEmptyProject, findAssetOf } from '../../src/core/model/Project';
|
||
import {
|
||
Button,
|
||
Checkbox,
|
||
ColorField,
|
||
Label,
|
||
NumberField,
|
||
TextField,
|
||
toHex,
|
||
VStack,
|
||
type Widget,
|
||
} from '../../src/ui';
|
||
import type { InspectorHost } from '../../src/editor/inspector/host';
|
||
import {
|
||
buildShapeListSection,
|
||
buildShapeSection,
|
||
refreshShapeSection,
|
||
setFillTexture,
|
||
setPaintKind,
|
||
shapeSignature,
|
||
} from '../../src/editor/inspector/shapeSection';
|
||
import { shapeDropGap, shapeDropMove, shapeRowOrder } from '../../src/editor/vectorart/shapeRows';
|
||
import { EditorState, shapeSelection } from '../../src/editor/state';
|
||
import { PaintCache } from '../../src/viewport/vectorArtPicture';
|
||
|
||
const ROW = 30;
|
||
|
||
/**
|
||
* These rows really are built, and a widget repaints itself one microtask after a value goes into
|
||
* it – text width included, which Pixi measures on a canvas of the browser. The suite runs on node,
|
||
* so the measure is answered here instead of the whole test avoiding the widgets.
|
||
*/
|
||
beforeAll(() => {
|
||
CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never;
|
||
// A gradient (#186) paints itself onto a browser canvas too; the rows under test do not need it.
|
||
PaintCache.prototype.gradient = (() => null) as never;
|
||
});
|
||
|
||
function makeHost(): InspectorHost {
|
||
const state = new EditorState();
|
||
state.loadProject(createEmptyProject('p', 'P'), 1);
|
||
return {
|
||
state,
|
||
body: new VStack(),
|
||
fields: new Map(),
|
||
nameField: null,
|
||
project: () => state.project,
|
||
liveVariables: () => null,
|
||
};
|
||
}
|
||
|
||
/** A vector art asset with `count` triangles in it, and a host to build its rows into. */
|
||
function withShapes(count: number): { host: InspectorHost; assetId: string; ids: string[] } {
|
||
const host = makeHost();
|
||
const asset = buildAsset('vectorart', 'Drawing') as VectorArtAsset;
|
||
host.state.project.assets.push(asset);
|
||
const ids: string[] = [];
|
||
for (let i = 0; i < count; i++) {
|
||
const command = new AddVectorShapeCommand(asset.id, [
|
||
{
|
||
closed: true,
|
||
points: [
|
||
{ x: i, y: 0 },
|
||
{ x: i + 20, y: 0 },
|
||
{ x: i, y: 20 },
|
||
],
|
||
},
|
||
]);
|
||
host.state.execute(command);
|
||
ids.push(command.shapeId);
|
||
}
|
||
return { host, assetId: asset.id, ids };
|
||
}
|
||
|
||
const shapeOf = (host: InspectorHost, assetId: string, index = 0) =>
|
||
findAssetOf(host.state.project, assetId, 'vectorart')!.shapes[index]!;
|
||
|
||
function widgets<T extends Widget>(host: InspectorHost, 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 texts = (host: InspectorHost): string[] => widgets(host, Label).map((label) => label.text);
|
||
|
||
describe('shape list order', () => {
|
||
it('shows the drawing order upside down – the shape drawn last is the top row', () => {
|
||
expect(shapeRowOrder(['bottom', 'middle', 'top'])).toEqual(['top', 'middle', 'bottom']);
|
||
});
|
||
|
||
it('drops into the gap the pointer is nearest to', () => {
|
||
expect(shapeDropGap(3, 2, ROW)).toBe(0);
|
||
expect(shapeDropGap(3, 20, ROW)).toBe(1);
|
||
expect(shapeDropGap(3, 44, ROW)).toBe(1);
|
||
expect(shapeDropGap(3, 46, ROW)).toBe(2);
|
||
// Outside the list there is no gap: a drop above it is the first, below it the last.
|
||
expect(shapeDropGap(3, 400, ROW)).toBe(3);
|
||
expect(shapeDropGap(3, -40, ROW)).toBe(0);
|
||
});
|
||
|
||
it('turns a drop into a move of the model, the top row being the last shape', () => {
|
||
// Three shapes, so the rows are the model indices [2, 1, 0]: the top row dropped at the
|
||
// bottom gap becomes the first shape of the model, the one drawn first.
|
||
expect(shapeDropMove(3, 0, 3)).toEqual({ from: 2, to: 0 });
|
||
expect(shapeDropMove(3, 2, 0)).toEqual({ from: 0, to: 2 });
|
||
expect(shapeDropMove(3, 1, 0)).toEqual({ from: 1, to: 2 });
|
||
});
|
||
|
||
it('is no move when the row lands where it already is', () => {
|
||
expect(shapeDropMove(3, 1, 1)).toBeNull();
|
||
expect(shapeDropMove(3, 1, 2)).toBeNull();
|
||
expect(shapeDropMove(3, 5, 1)).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('shape rows', () => {
|
||
it('shows fill, opacity and an outline switch, and no line rows without an outline', () => {
|
||
const { host, assetId, ids } = withShapes(1);
|
||
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||
expect([...host.fields.keys()]).toEqual(['name', 'fillType', 'fill', 'opacity', 'outline']);
|
||
expect(texts(host)).toContain('Without an outline only the fill is drawn.');
|
||
});
|
||
|
||
it('writes a fill as one undo step, a dragged colour previewing without one', () => {
|
||
const { host, assetId, ids } = withShapes(1);
|
||
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||
const before = shapeOf(host, assetId).fill;
|
||
const fill = widgets(host, ColorField)[0]!;
|
||
fill.onInput!(0xff0000);
|
||
expect(shapeOf(host, assetId).fill).toBe(0xff0000);
|
||
fill.onChange!(0x00ff00);
|
||
expect(shapeOf(host, assetId).fill).toBe(0x00ff00);
|
||
host.state.undo();
|
||
expect(shapeOf(host, assetId).fill).toBe(before);
|
||
});
|
||
|
||
it('writes the opacity, clamped to what an opacity can be', () => {
|
||
const { host, assetId, ids } = withShapes(1);
|
||
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||
// A NumberField reads text: its own `onChange` is the one a typed value goes through.
|
||
const opacity = widgets(host, NumberField)[0]!;
|
||
opacity.onChange!('0.4');
|
||
expect(shapeOf(host, assetId).opacity).toBeCloseTo(0.4);
|
||
opacity.onChange!('5');
|
||
expect(shapeOf(host, assetId).opacity).toBe(1);
|
||
});
|
||
|
||
it('the outline switch adds a line and takes it away again', () => {
|
||
const { host, assetId, ids } = withShapes(1);
|
||
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||
widgets(host, Checkbox)[0]!.toggle();
|
||
expect(shapeOf(host, assetId).stroke).toEqual({ color: 0x000000, width: 2 });
|
||
host.state.undo();
|
||
expect(shapeOf(host, assetId).stroke).toBeUndefined();
|
||
});
|
||
|
||
it('shows the line rows once there is a line, and a width of nothing is not one', () => {
|
||
const { host, assetId, ids } = withShapes(1);
|
||
shapeOf(host, assetId).stroke = { color: 0x112233, width: 3 };
|
||
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||
expect([...host.fields.keys()]).toEqual([
|
||
'name',
|
||
'fillType',
|
||
'fill',
|
||
'opacity',
|
||
'outline',
|
||
'lineType',
|
||
'strokeColor',
|
||
'strokeWidth',
|
||
]);
|
||
const width = widgets(host, NumberField)[1]!;
|
||
// The command refuses a line of no width, so the row never sends one.
|
||
width.onChange!('0');
|
||
expect(shapeOf(host, assetId).stroke?.width).toBe(0.1);
|
||
widgets(host, ColorField)[1]!.onChange!(0xabcdef);
|
||
expect(shapeOf(host, assetId).stroke).toEqual({ color: 0xabcdef, width: 0.1 });
|
||
});
|
||
|
||
it('names the shapes and the outline in its signature, so both rebuild the rows', () => {
|
||
const { host, assetId, ids } = withShapes(2);
|
||
const before = shapeSignature(host.state.project, assetId, ids[0]!, [ids[0]!]);
|
||
shapeOf(host, assetId).name = 'Hat';
|
||
const renamed = shapeSignature(host.state.project, assetId, ids[0]!, [ids[0]!]);
|
||
expect(renamed).not.toBe(before);
|
||
shapeOf(host, assetId).stroke = { color: 0, width: 1 };
|
||
expect(shapeSignature(host.state.project, assetId, ids[0]!, [ids[0]!])).not.toBe(renamed);
|
||
});
|
||
|
||
it('a shape that is gone asks for a rebuild instead of refreshing', () => {
|
||
const { host, assetId, ids } = withShapes(1);
|
||
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||
expect(refreshShapeSection(host, assetId, ids[0]!)).toBe(true);
|
||
expect(refreshShapeSection(host, assetId, 'gone')).toBe(false);
|
||
expect(shapeSignature(host.state.project, assetId, 'gone', ['gone'])).toBe('missing');
|
||
});
|
||
|
||
it('refreshing pushes the model values back into the rows', () => {
|
||
const { host, assetId, ids } = withShapes(1);
|
||
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||
const shape = shapeOf(host, assetId);
|
||
shape.fill = 0x123456;
|
||
shape.name = 'Cloud';
|
||
refreshShapeSection(host, assetId, ids[0]!);
|
||
// The name field first, then the hex text inside the fill row – a name is a field, not a label.
|
||
const fields = widgets(host, TextField);
|
||
expect(fields[0]!.value).toBe('Cloud');
|
||
expect(fields[1]!.value).toBe(toHex(0x123456));
|
||
});
|
||
|
||
it('the asset itself shows the list and the way to add to it', () => {
|
||
const { host, assetId } = withShapes(2);
|
||
buildShapeListSection(host, findAssetOf(host.state.project, assetId, 'vectorart')!);
|
||
expect(texts(host)).toContain('SHAPES');
|
||
expect(texts(host)).toContain('The top row is drawn over the ones below it.');
|
||
});
|
||
|
||
it('an empty drawing says so instead of showing an empty list', () => {
|
||
const { host, assetId } = withShapes(0);
|
||
buildShapeListSection(host, findAssetOf(host.state.project, assetId, 'vectorart')!);
|
||
expect(texts(host).some((t) => t.startsWith('Nothing drawn yet'))).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('combining from the inspector (#187)', () => {
|
||
const button = (host: InspectorHost, text: string) => widgets(host, Button).find((b) => b.text === text)!;
|
||
|
||
it('Ctrl+click adds a shape and takes it out again; emptied, the drawing stays selected', () => {
|
||
const { host, assetId, ids } = withShapes(2);
|
||
host.state.select(shapeSelection(assetId, [ids[0]!]));
|
||
host.state.toggleShape(assetId, ids[1]!);
|
||
expect(host.state.selection).toMatchObject({ shapeId: ids[1], shapeIds: [ids[0], ids[1]] });
|
||
host.state.toggleShape(assetId, ids[1]!);
|
||
host.state.toggleShape(assetId, ids[0]!);
|
||
expect(host.state.selection).toEqual({ kind: 'asset', assetId });
|
||
});
|
||
|
||
it('switches the four buttons off for one shape and says how to get a second', () => {
|
||
const { host, assetId, ids } = withShapes(2);
|
||
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||
expect(['Union', 'Subtract', 'Intersect', 'Exclude'].map((t) => button(host, t).eventMode)).toEqual([
|
||
'none',
|
||
'none',
|
||
'none',
|
||
'none',
|
||
]);
|
||
expect(texts(host)).toContain('Ctrl+click a second shape to combine.');
|
||
});
|
||
|
||
it('combines the selected shapes into the lowest one and selects it', () => {
|
||
const { host, assetId, ids } = withShapes(3);
|
||
host.state.select(shapeSelection(assetId, [ids[2]!, ids[1]!]));
|
||
buildShapeSection(host, assetId, ids[1]!, [ids[2]!, ids[1]!]);
|
||
expect(texts(host)).toContain('The result keeps "Shape 2", the lowest.');
|
||
button(host, 'Union').onClick!();
|
||
expect(findAssetOf(host.state.project, assetId, 'vectorart')!.shapes.map((s) => s.id)).toEqual([
|
||
ids[0],
|
||
ids[1],
|
||
]);
|
||
expect(host.state.selection).toMatchObject({ shapeId: ids[1], shapeIds: [ids[1]] });
|
||
});
|
||
|
||
it('drops a selected shape that is gone and keeps the rest', () => {
|
||
const { host, assetId, ids } = withShapes(3);
|
||
host.state.select(shapeSelection(assetId, [ids[0]!, ids[2]!]));
|
||
host.state.execute(new RemoveVectorShapeCommand(assetId, ids[2]!));
|
||
expect(host.state.selection).toMatchObject({ shapeId: ids[0], shapeIds: [ids[0]] });
|
||
});
|
||
|
||
it('names the selected shapes in the signature, so a Ctrl+click rebuilds the rows', () => {
|
||
const { host, assetId, ids } = withShapes(2);
|
||
const project = host.state.project;
|
||
expect(shapeSignature(project, assetId, ids[0]!, [ids[0]!])).not.toBe(
|
||
shapeSignature(project, assetId, ids[0]!, [ids[1]!, ids[0]!]),
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('fill and line types (#186)', () => {
|
||
const button = (host: InspectorHost, text: string) => widgets(host, Button).find((b) => b.text === text)!;
|
||
const rebuild = (host: InspectorHost, assetId: string, shapeId: string) => {
|
||
host.body.removeChildren();
|
||
host.fields.clear();
|
||
buildShapeSection(host, assetId, shapeId, [shapeId]);
|
||
};
|
||
|
||
it('switches the fill to a gradient in one undo step, and the colour row makes way for the stops', () => {
|
||
const { host, assetId, ids } = withShapes(1);
|
||
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||
setPaintKind(host.state, assetId, ids[0]!, 'fill', 'linear');
|
||
expect(shapeOf(host, assetId).paint).toMatchObject({
|
||
type: 'linear',
|
||
stops: [{ offset: 0 }, { offset: 1 }],
|
||
});
|
||
const signature = shapeSignature(host.state.project, assetId, ids[0]!, [ids[0]!]);
|
||
expect(signature).toContain('linear/none');
|
||
rebuild(host, assetId, ids[0]!);
|
||
expect([...host.fields.keys()]).toEqual([
|
||
'name',
|
||
'fillType',
|
||
'fillStopColor',
|
||
'fillStopAlpha',
|
||
'fillStopOffset',
|
||
'opacity',
|
||
'outline',
|
||
]);
|
||
host.state.undo();
|
||
expect(shapeOf(host, assetId).paint).toBeUndefined();
|
||
});
|
||
|
||
it('edits the picked stop, and "Remove stop" only works while three are left', () => {
|
||
const { host, assetId, ids } = withShapes(1);
|
||
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||
setPaintKind(host.state, assetId, ids[0]!, 'fill', 'radial');
|
||
rebuild(host, assetId, ids[0]!);
|
||
// The stop's colour is the only colour row left; opacity of the stop is the first number row.
|
||
widgets(host, ColorField)[0]!.onChange!(0xff0000);
|
||
widgets(host, NumberField)[0]!.onChange!('0.5');
|
||
const paint = shapeOf(host, assetId).paint as VectorGradient;
|
||
expect(paint.stops[0]).toEqual({ offset: 0, color: 0xff0000, alpha: 0.5 });
|
||
expect(button(host, 'Remove stop').eventMode).toBe('none');
|
||
|
||
host.state.execute(
|
||
new SetVectorShapeStyleCommand(assetId, ids[0]!, {
|
||
paint: { ...paint, stops: [...paint.stops, { offset: 0.5, color: 0, alpha: 1 }] },
|
||
}),
|
||
);
|
||
rebuild(host, assetId, ids[0]!);
|
||
expect(button(host, 'Remove stop').eventMode).not.toBe('none');
|
||
button(host, 'Remove stop').onClick!();
|
||
expect((shapeOf(host, assetId).paint as VectorGradient).stops.map((s) => s.offset)).toEqual([1, 0.5]);
|
||
});
|
||
|
||
it('gives the outline a type of its own, with the line colour as its first stop', () => {
|
||
const { host, assetId, ids } = withShapes(1);
|
||
shapeOf(host, assetId).stroke = { color: 0x112233, width: 3 };
|
||
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||
setPaintKind(host.state, assetId, ids[0]!, 'stroke', 'linear');
|
||
expect(shapeOf(host, assetId).stroke).toMatchObject({
|
||
color: 0x112233,
|
||
width: 3,
|
||
paint: { type: 'linear' },
|
||
});
|
||
expect((shapeOf(host, assetId).stroke!.paint as VectorGradient).stops[0]?.color).toBe(0x112233);
|
||
setPaintKind(host.state, assetId, ids[0]!, 'stroke', 'solid');
|
||
expect(shapeOf(host, assetId).stroke).toEqual({ color: 0x112233, width: 3 });
|
||
});
|
||
|
||
it('a texture fill shows the picker, and picking writes the id into the paint', () => {
|
||
const { host, assetId, ids } = withShapes(1);
|
||
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||
setPaintKind(host.state, assetId, ids[0]!, 'fill', 'texture');
|
||
rebuild(host, assetId, ids[0]!);
|
||
expect(host.fields.has('fillTexture')).toBe(true);
|
||
setFillTexture(host.state, assetId, ids[0]!, 't1');
|
||
expect(shapeOf(host, assetId).paint).toMatchObject({ type: 'texture', texture: 't1' });
|
||
});
|
||
});
|