Files
feuerwerk/tests/core/vector-boolean.test.ts
T
adminandClaude Opus 5 1116818011
game-maker / gate (push) Successful in 2m13s
game-maker / suite (push) Successful in 7m51s
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>
2026-09-21 11:56:41 +02:00

208 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});
});