Vector shapes can be combined by union, subtract and more (#187)
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>
This commit is contained in:
1 parent
3427f84a55
commit
1116818011
14 files changed
+796
-55
No files matched your search
@@ -5,11 +5,12 @@
|
||||
*/
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import { CanvasTextMetrics } from 'pixi.js';
|
||||
import { AddVectorShapeCommand } from '../../src/core/commands/vectorArtCommands';
|
||||
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,
|
||||
@@ -27,7 +28,7 @@ import {
|
||||
shapeSignature,
|
||||
} from '../../src/editor/inspector/shapeSection';
|
||||
import { shapeDropGap, shapeDropMove, shapeRowOrder } from '../../src/editor/vectorart/shapeRows';
|
||||
import { EditorState } from '../../src/editor/state';
|
||||
import { EditorState, shapeSelection } from '../../src/editor/state';
|
||||
|
||||
const ROW = 30;
|
||||
|
||||
@@ -124,14 +125,14 @@ describe('shape list order', () => {
|
||||
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]!);
|
||||
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]!);
|
||||
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||||
const before = shapeOf(host, assetId).fill;
|
||||
const fill = widgets(host, ColorField)[0]!;
|
||||
fill.onInput!(0xff0000);
|
||||
@@ -144,7 +145,7 @@ describe('shape rows', () => {
|
||||
|
||||
it('writes the opacity, clamped to what an opacity can be', () => {
|
||||
const { host, assetId, ids } = withShapes(1);
|
||||
buildShapeSection(host, assetId, ids[0]!);
|
||||
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');
|
||||
@@ -155,7 +156,7 @@ describe('shape rows', () => {
|
||||
|
||||
it('the outline switch adds a line and takes it away again', () => {
|
||||
const { host, assetId, ids } = withShapes(1);
|
||||
buildShapeSection(host, assetId, ids[0]!);
|
||||
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();
|
||||
@@ -165,7 +166,7 @@ describe('shape rows', () => {
|
||||
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]!);
|
||||
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||||
expect([...host.fields.keys()]).toEqual([
|
||||
'name',
|
||||
'fill',
|
||||
@@ -184,25 +185,25 @@ describe('shape rows', () => {
|
||||
|
||||
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]!);
|
||||
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]!);
|
||||
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]!)).not.toBe(renamed);
|
||||
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]!);
|
||||
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')).toBe('missing');
|
||||
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]!);
|
||||
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||||
const shape = shapeOf(host, assetId);
|
||||
shape.fill = 0x123456;
|
||||
shape.name = 'Cloud';
|
||||
@@ -226,3 +227,57 @@ describe('shape rows', () => {
|
||||
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]!]),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user