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);
});
});