Vector shapes can be combined by union, subtract and more (#187)
game-maker / gate (push) Successful in 2m13s
game-maker / suite (push) Successful in 7m51s

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:
adminandClaude Opus 5 committed 2026-09-21 11:56:41 +02:00
1 parent 3427f84a55
commit 1116818011
14 files changed
+796 -55

No files matched your search

+207
View File
@@ -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);
});
});
+68 -13
View File
@@ -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]!]),
);
});
});