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
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Boolean operations on vector art shapes (#187). The areas are measured by sampling the result
|
||||
* under the even-odd rule the rasteriser fills with, so a test sees what would be painted – not
|
||||
* merely how many rings came out.
|
||||
*/
|
||||
import { Polygon } from 'pixi.js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||
import {
|
||||
AddVectorShapeCommand,
|
||||
CombineVectorShapesCommand,
|
||||
RemoveVectorShapesCommand,
|
||||
SetVectorShapeStyleCommand,
|
||||
} from '../../src/core/commands/vectorArtCommands';
|
||||
import type { VectorArtAsset, VectorRing, VectorShape } from '../../src/core/model/Asset';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import {
|
||||
combineBlocker,
|
||||
combinedRings,
|
||||
FLATTEN_TOLERANCE,
|
||||
type ShapeBoolean,
|
||||
} from '../../src/core/model/vectorBoolean';
|
||||
import { makeProject } from '../helpers';
|
||||
|
||||
function square(x: number, y: number, side: number): VectorRing {
|
||||
return {
|
||||
closed: true,
|
||||
points: [
|
||||
{ x, y },
|
||||
{ x: x + side, y },
|
||||
{ x: x + side, y: y + side },
|
||||
{ x, y: y + side },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** A circle of four symmetric points, the handles a third of a quarter arc long (kappa). */
|
||||
function circle(cx: number, cy: number, r: number): VectorRing {
|
||||
const k = 0.5523 * r;
|
||||
return {
|
||||
closed: true,
|
||||
points: [
|
||||
{ x: cx + r, y: cy, in: { x: 0, y: -k }, out: { x: 0, y: k } },
|
||||
{ x: cx, y: cy + r, in: { x: k, y: 0 }, out: { x: -k, y: 0 } },
|
||||
{ x: cx - r, y: cy, in: { x: 0, y: k }, out: { x: 0, y: -k } },
|
||||
{ x: cx, y: cy - r, in: { x: -k, y: 0 }, out: { x: k, y: 0 } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function shape(id: string, ...rings: VectorRing[]): VectorShape {
|
||||
return { id, name: id, rings, fill: 0x808080, opacity: 1 };
|
||||
}
|
||||
|
||||
function inside(ring: VectorRing, x: number, y: number): boolean {
|
||||
let hit = false;
|
||||
const p = ring.points;
|
||||
for (let i = 0, j = p.length - 1; i < p.length; j = i++) {
|
||||
const a = p[i]!;
|
||||
const b = p[j]!;
|
||||
if (a.y > y !== b.y > y && x < ((b.x - a.x) * (y - a.y)) / (b.y - a.y) + a.x) hit = !hit;
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
/** The area painted under even-odd, counted at the centres of half-pixel cells. */
|
||||
function paintedArea(rings: readonly VectorRing[], from = -5, to = 60): number {
|
||||
let cells = 0;
|
||||
for (let x = from + 0.25; x < to; x += 0.5)
|
||||
for (let y = from + 0.25; y < to; y += 0.5)
|
||||
if (rings.filter((ring) => inside(ring, x, y)).length % 2 === 1) cells += 1;
|
||||
return cells * 0.25;
|
||||
}
|
||||
|
||||
const A = () => shape('a', square(0, 0, 20));
|
||||
const B = () => shape('b', square(10, 10, 20));
|
||||
|
||||
describe('combining shapes', () => {
|
||||
it.each<[ShapeBoolean, number]>([
|
||||
['union', 700],
|
||||
['subtract', 300],
|
||||
['intersect', 100],
|
||||
['exclude', 600],
|
||||
])('%s of two overlapping squares paints %i square pixels', (op, area) => {
|
||||
expect(paintedArea(combinedRings([A(), B()], op))).toBe(area);
|
||||
});
|
||||
|
||||
it('subtracts every later shape from the first, whatever order they overlap in', () => {
|
||||
const c = shape('c', square(0, 10, 5));
|
||||
// 400 − the 100 B takes − the 25 C takes: C and B do not overlap inside A.
|
||||
expect(paintedArea(combinedRings([A(), B(), c], 'subtract'))).toBe(275);
|
||||
});
|
||||
|
||||
it('reads each shape by the even-odd rule: an overlap of its own rings is a hole', () => {
|
||||
const crossed = shape('x', square(0, 0, 20), square(10, 0, 20));
|
||||
// The two rings overlap in 10×20 – even-odd leaves that empty: 800 − 2·200 = 400.
|
||||
const far = shape('f', square(40, 40, 10));
|
||||
expect(paintedArea(combinedRings([crossed, far], 'union'))).toBe(500);
|
||||
});
|
||||
|
||||
it('puts an island in a hole before its outline, so Pixi does not take it for a hole', () => {
|
||||
const donut = shape('d', square(0, 0, 50), square(10, 10, 30));
|
||||
const island = shape('i', square(20, 20, 10));
|
||||
const rings = combinedRings([donut, island], 'union');
|
||||
expect(paintedArea(rings)).toBe(2500 - 900 + 100);
|
||||
// Pixi's own rule (ShapePath.addPath with checkForHoles): a polygon is a hole of the last
|
||||
// outline when that one contains it. The island has to come out as an outline of its own.
|
||||
let main: { poly: Polygon; holes: number } | null = null;
|
||||
const outlines: { poly: Polygon; holes: number }[] = [];
|
||||
for (const ring of rings) {
|
||||
const poly = new Polygon(ring.points.flatMap((p) => [p.x, p.y]));
|
||||
if (main && main.poly.containsPolygon(poly)) main.holes += 1;
|
||||
else outlines.push((main = { poly, holes: 0 }));
|
||||
}
|
||||
expect(outlines.map((o) => o.holes).sort()).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('samples a curve within the tolerance and keeps its points few', () => {
|
||||
const round = shape('r', circle(30, 30, 20));
|
||||
const far = shape('f', square(100, 100, 5));
|
||||
const rings = combinedRings([round, far], 'union');
|
||||
const disc = rings.find((ring) => ring.points.every((p) => p.x < 60))!;
|
||||
const radii = disc.points.map((p) => Math.hypot(p.x - 30, p.y - 30));
|
||||
// Kappa bulges the true curve out by 0.03 % of r; rounding to a hundredth adds 0.01.
|
||||
expect(Math.max(...radii)).toBeLessThan(20 + 0.02);
|
||||
expect(Math.min(...radii)).toBeGreaterThan(20 - FLATTEN_TOLERANCE - 0.02);
|
||||
expect(disc.points.length).toBeGreaterThan(16);
|
||||
expect(disc.points.length).toBeLessThan(64);
|
||||
expect(disc.points.every((p) => !p.in && !p.out)).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses one shape alone and an open line, and says why', () => {
|
||||
expect(combineBlocker([A()])).toMatch(/second shape/);
|
||||
const line = shape('l', {
|
||||
closed: false,
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 5, y: 5 },
|
||||
],
|
||||
});
|
||||
expect(combineBlocker([A(), line])).toMatch(/open line/);
|
||||
// A line of one point is still being drawn and draws nothing – it is passed over.
|
||||
const stub = shape('s', square(40, 0, 5), { closed: false, points: [{ x: 0, y: 0 }] });
|
||||
expect(combineBlocker([A(), stub])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function drawingWith(...rings: VectorRing[]) {
|
||||
const fixture = makeProject();
|
||||
const create = new CreateAssetCommand('vectorart', 'Logo');
|
||||
fixture.stack.execute(create);
|
||||
const assetId = create.asset.id;
|
||||
const ids = rings.map((ring) => {
|
||||
const add = new AddVectorShapeCommand(assetId, [ring]);
|
||||
fixture.stack.execute(add);
|
||||
return add.shapeId;
|
||||
});
|
||||
const art = () => findAsset(fixture.project, assetId) as VectorArtAsset;
|
||||
return { ...fixture, assetId, ids, art };
|
||||
}
|
||||
|
||||
describe('the combine command', () => {
|
||||
it('writes the result into the lowest shape, keeps its look and place, and removes the rest', () => {
|
||||
const f = drawingWith(square(40, 40, 10), square(0, 0, 20), square(10, 10, 20));
|
||||
const [first, low, high] = f.ids as [string, string, string];
|
||||
f.stack.execute(
|
||||
new SetVectorShapeStyleCommand(f.assetId, low, { fill: 0xff0000, stroke: { color: 0, width: 2 } }),
|
||||
);
|
||||
// Picked top first: the order of the selection does not decide, the drawing order does.
|
||||
const combine = new CombineVectorShapesCommand(f.assetId, [high, low], 'subtract');
|
||||
f.stack.execute(combine);
|
||||
expect(combine.shapeId).toBe(low);
|
||||
expect(f.art().shapes.map((s) => s.id)).toEqual([first, low]);
|
||||
const result = f.art().shapes[1]!;
|
||||
expect(result).toMatchObject({ name: 'Shape 2', fill: 0xff0000, stroke: { color: 0, width: 2 } });
|
||||
expect(paintedArea(result.rings)).toBe(300);
|
||||
});
|
||||
|
||||
it('is one undo step that puts every shape back as it was', () => {
|
||||
const f = drawingWith(square(0, 0, 20), square(10, 10, 20), square(40, 40, 10));
|
||||
const before = JSON.stringify(f.art().shapes);
|
||||
f.stack.execute(new CombineVectorShapesCommand(f.assetId, f.ids, 'union'));
|
||||
expect(f.art().shapes).toHaveLength(1);
|
||||
f.stack.undo();
|
||||
expect(JSON.stringify(f.art().shapes)).toBe(before);
|
||||
f.stack.redo();
|
||||
expect(paintedArea(f.art().shapes[0]!.rings)).toBe(800);
|
||||
});
|
||||
|
||||
it('refuses a result of nothing and leaves the drawing alone', () => {
|
||||
const f = drawingWith(square(0, 0, 10), square(30, 30, 10));
|
||||
const before = JSON.stringify(f.art().shapes);
|
||||
expect(() => f.stack.execute(new CombineVectorShapesCommand(f.assetId, f.ids, 'intersect'))).toThrow(
|
||||
/nothing in common/,
|
||||
);
|
||||
expect(JSON.stringify(f.art().shapes)).toBe(before);
|
||||
});
|
||||
|
||||
it('removes several shapes in one step and brings them back in their places', () => {
|
||||
const f = drawingWith(square(0, 0, 5), square(10, 0, 5), square(20, 0, 5));
|
||||
const before = f.art().shapes.map((s) => s.id);
|
||||
f.stack.execute(new RemoveVectorShapesCommand(f.assetId, [f.ids[0]!, f.ids[2]!]));
|
||||
expect(f.art().shapes.map((s) => s.id)).toEqual([f.ids[1]]);
|
||||
f.stack.undo();
|
||||
expect(f.art().shapes.map((s) => s.id)).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -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