Building a shape out of simpler ones meant drawing the result point by point. The Vector Art tab now selects several shapes at once and combines them with a boolean operation into one ordinary shape. - Ctrl/Cmd+click in the tab and Ctrl or Shift+click in the shape list add a shape to the selection. Shift stays pan in the canvas. - A COMBINE block in the inspector offers Union, Subtract, Intersect and Exclude. Subtract removes every upper shape from the lowest one. - The result replaces the lowest selected shape and keeps its name, colours, stroke and place in the order. It is one undo step. - Curves are flattened to 0.2 canvas pixels, a circle gets about 48 points. Open lines and single shapes cannot be combined, the buttons are off and a hint gives the reason. An empty result is rejected. - Delete now removes every selected shape, and the selection drops shapes that no longer exist. - The geometry comes from clipper2-ts 2.0.1, pinned. It has no dependencies and speaks even-odd directly. Only the engine module is imported, the bundle grows by 55 kB instead of 110 kB. Findings: - clipper2-js was tried and dropped. It pulls in Angular 15 with a high npm audit finding and no fix. - Pixi finds holes by containment, not by even-odd depth. An island inside a hole would be drawn as a hole again. Result rings are emitted outer ring before its islands, and a test mirrors Pixi's rule. - A depth sort turned out to be dead code in a counter-check and was removed. A pixel check in 47h stayed green without the subtract and now checks both colours. Tests: - ./check.sh: 134 files, 1986 tests, exit 0 (before 133 / 1968) - npm run build: exit 0 - GM_STEPS=47, freshly seeded: ALL PASSED, 44 checks (before 39) - 18 new unit tests, 5 new e2e checks in block 47h - Five counter-checks bite, originals restored and verified with cmp - Check sum 1222 in README and wiki, calculated (1217 + 5) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
284 lines
12 KiB
TypeScript
284 lines
12 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 } from '../../src/core/commands/vectorArtCommands';
|
||
import type { VectorArtAsset } 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,
|
||
shapeSignature,
|
||
} from '../../src/editor/inspector/shapeSection';
|
||
import { shapeDropGap, shapeDropMove, shapeRowOrder } from '../../src/editor/vectorart/shapeRows';
|
||
import { EditorState, shapeSelection } from '../../src/editor/state';
|
||
|
||
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;
|
||
});
|
||
|
||
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', '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',
|
||
'fill',
|
||
'opacity',
|
||
'outline',
|
||
'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]!]),
|
||
);
|
||
});
|
||
});
|