Files
feuerwerk/tests/core/vector-boolean.test.ts
T
adminandClaude Opus 5.5 2ff3a78607
game-maker / gate (push) Successful in 2m41s
game-maker / suite (push) Failing after 7h24m17s
Put the shapes of a vector drawing on layers and in groups (#190)
A vector drawing was one flat list of shapes. With many shapes the
list got hard to read, and several shapes could not be kept or moved
together. Now the shapes stand on layers, as in pixel art, and can be
put into groups that nest.

- A drawing has layers, each with a name and an eye. A hidden layer
  is neither in the tab nor in the picture a sprite wears, and its
  shapes cannot be pressed. One layer is the one worked on: the pen
  and Add shape draw onto it, and SHAPES lists what is on it.
- The inspector has a list LAYERS over the shapes. Its plus adds a
  layer, the cross on a row removes it with all on it, the last one
  stays. A double-click renames, a drag sorts.
- Group (Ctrl+G) puts the selected shapes into a group, Ungroup
  (Ctrl+Shift+G) takes it away. A group stands over its rows in the
  list, the rows one step in, and a row is dragged into a group or
  out of it.
- In the tab a press on a shape takes its outermost group, and a drag
  moves the group as one undo step.
- The model has layers with a tree of shapes and groups instead of a
  flat shapes array. The rules are pure functions in
  core/model/vectorTree.ts. A drawing saved before opens with all its
  shapes on one layer.
- SortableList takes rows with a depth and drops into a tree. The
  flat lists of tilemap and pixel art run as before.

Decided here:

- A group has no transformation of its own and cannot be hidden or
  locked. Rotating and scaling is the follow-up #291.
- A group is never empty: the one that loses its last shape goes
  with it.
- Several selected shapes are dragged together, grouped or not, and
  a click on a shape of the selection keeps the selection. This is
  what #217 asked for.
- Shift+click selects like Ctrl+click. With Select, Shift pans only
  beside the shapes, as in the scene view. The middle button pans
  anywhere, and with the pen and Paint Shift pans as before.
- A row of the list picks one shape of a group alone.
- Shapes change their layer through a row Layer in the inspector,
  which is there from two layers on. Rows are not dragged between
  layers.
- A group cannot span two layers, the button is off then.
- The pen does not draw onto a hidden layer, the hint says so.
- Groups are always shown unfolded.
- The four order buttons work inside the own group or layer.

Tests:
- Gate: 204 files, 3067 tests (3008 + 59), exit 0
- e2e 47,48: 108 checks. e2e 39,40,47,48 before the Shift change:
  209 checks. No full run, the sum of 1645 (1639 + 6) is reckoned
  and CI measures it.
- 59 unit tests: 36 in tests/core/vector-tree.test.ts, 23 in
  tests/editor/vector-layers.test.ts
- Six e2e checks with a real mouse in block 47j
- 47 unit counter-probes for layers and groups and three for the
  Shift click all fail tests, run on stein2
- Three browser counter-probes fail their checks
- Two screenshots looked at (group in the list, hidden layer)
- Only unit tested: the row Layer, sorting and removing layers,
  copying a group, the Shift pan beside the shapes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:13:43 +02:00

319 lines
14 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 type { Vec2 } from '../../src/core/model/Properties';
import { findAsset } from '../../src/core/model/Project';
import {
combineBlocker,
combinedRings,
FLATTEN_TOLERANCE,
flattenRing,
type ShapeBoolean,
} from '../../src/core/model/vectorBoolean';
import { allShapes } from '../../src/core/model/vectorTree';
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(p: readonly Vec2[], x: number, y: number): boolean {
let hit = false;
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. The rings are
* sampled into corners first, so a result that came back as curves is measured along its curves.
*/
function paintedArea(rings: readonly VectorRing[], from = -5, to = 60): number {
const outlines = rings.map((ring) => flattenRing(ring));
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 (outlines.filter((outline) => inside(outline, 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(flattenRing(ring).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('gives a curve nothing crossed back as the same four points and handles (#208)', () => {
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))!;
expect(disc.points).toHaveLength(4);
expect(disc.points.every((p) => p.in && p.out)).toBe(true);
// The drawn curve, not merely its points: every sample of it sits on the circle.
const radii = flattenRing(disc).map((p) => Math.hypot(p.x - 30, p.y - 30));
expect(Math.max(...radii)).toBeLessThan(20 + 0.02);
expect(Math.min(...radii)).toBeGreaterThan(20 - FLATTEN_TOLERANCE - 0.02);
expect(flattenRing(disc).length).toBeGreaterThan(16);
});
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();
});
});
/** How far the ring's own outline strays from the circle it should be part of. */
function offCircle(ring: VectorRing, cx: number, cy: number, r: number, where: (p: Vec2) => boolean) {
const gaps = flattenRing(ring)
.filter(where)
.map((p) => Math.abs(Math.hypot(p.x - cx, p.y - cy) - r));
return { worst: Math.max(...gaps), samples: gaps.length };
}
describe('curves through a boolean operation (#208)', () => {
it('keeps the untouched side of a cut circle a curve, and the cut itself straight', () => {
// The square covers the left half, so only the left side of the circle is really cut.
const round = shape('r', circle(30, 30, 20));
const box = shape('b', square(-30, -10, 60));
const rings = combinedRings([round, box], 'subtract');
expect(rings).toHaveLength(1);
const points = rings[0]!.points;
expect(points.length).toBeLessThan(8);
expect(points.some((p) => p.in ?? p.out)).toBe(true);
// The right half is still the circle, sampled along the curve that came back.
const right = offCircle(rings[0]!, 30, 30, 20, (p) => p.x > 31);
expect(right.samples).toBeGreaterThan(8);
expect(right.worst).toBeLessThan(0.05);
// The cut runs down x = 30: two corners there, nothing bulging past it.
expect(Math.max(...flattenRing(rings[0]!).map((p) => 30 - p.x))).toBeLessThan(0.02);
});
it('cuts a curve where it is crossed and keeps that piece on the old curve', () => {
const round = shape('r', circle(30, 30, 20));
const box = shape('b', square(-10, -10, 35));
// The square's corner reaches into the circle and cuts two of its four curves in the middle.
const rings = combinedRings([round, box], 'subtract');
// A handful of points, not the forty of the sampled outline, and most of them still curved.
expect(rings[0]!.points.length).toBeLessThan(10);
expect(rings[0]!.points.filter((p) => p.in ?? p.out).length).toBeGreaterThanOrEqual(4);
// Everything outside the square's two cutting edges is circle, right up to the cuts.
const kept = offCircle(rings[0]!, 30, 30, 20, (p) => p.x > 25.05 || p.y > 25.05);
expect(kept.samples).toBeGreaterThan(15);
expect(kept.worst).toBeLessThan(0.05);
});
it('leaves a chord that cuts one curve twice a straight one', () => {
const round = shape('r', circle(30, 30, 20));
// A wedge that slices a cap off the upper right quarter: both its cuts land on that one curve,
// so the edge between them is a chord the clipper drew, not a piece of the circle.
const wedge = shape('w', {
closed: true,
points: [
{ x: 30, y: 6.83 },
{ x: 55, y: 31.83 },
{ x: 90, y: -30 },
],
});
const rings = combinedRings([round, wedge], 'subtract');
expect(rings).toHaveLength(1);
// Nothing of the outline reaches past the chord: the cap of 3.6 pixels is gone and no curve
// bulges back over it. The chord runs along x − y = 23.17, the centre lies below it.
expect(Math.max(...flattenRing(rings[0]!).map((p) => p.x - p.y))).toBeLessThan(23.3);
// The rest of the circle is still curved.
expect(rings[0]!.points.filter((p) => p.in ?? p.out).length).toBeGreaterThanOrEqual(4);
});
it('leaves a square a square: no handle grows on a straight edge', () => {
const rings = combinedRings([A(), B()], 'union');
expect(rings).toHaveLength(1);
expect(rings[0]!.points.every((p) => !p.in && !p.out)).toBe(true);
expect(rings[0]!.points).toHaveLength(8);
});
it('keeps both curves of two circles that overlap, and paints the same area as before', () => {
const left = shape('l', circle(25, 30, 15));
const right = shape('r', circle(40, 30, 15));
const rings = combinedRings([left, right], 'union');
expect(rings).toHaveLength(1);
// Six of the eight points are the circles' own anchors, two are the intersections.
expect(rings[0]!.points).toHaveLength(8);
// Every drawn point sits on one of the two circles – the whole outline, intersections included,
// not merely the sides nothing crossed.
const off = flattenRing(rings[0]!).map((p) =>
Math.min(Math.abs(Math.hypot(p.x - 25, p.y - 30) - 15), Math.abs(Math.hypot(p.x - 40, p.y - 30) - 15)),
);
expect(Math.max(...off)).toBeLessThan(0.05);
});
it('never leaves a ring with fewer than three points', () => {
// Two circles offset diagonally: no anchor of either one lies inside the other, so the lens
// between them is geometrically two points and two curves.
const lens = combinedRings([shape('a', circle(20, 20, 15)), shape('b', circle(40, 40, 15))], 'intersect');
expect(lens).toHaveLength(1);
// A ring of two is not drawable, so one curve is left open at a third point instead.
expect(lens[0]!.points).toHaveLength(3);
expect(lens[0]!.points.filter((p) => p.in ?? p.out)).toHaveLength(3);
// Both curves of the lens are still the circles they were cut from. The two cut points
// themselves are the clipper's, and it crosses the sampled chords, not the curves – measured
// 0.09 out, which is what the sampling tolerance allows and what it always was.
const knots = new Set(lens[0]!.points.map((p) => `${p.x},${p.y}`));
for (const p of flattenRing(lens[0]!)) {
const off = Math.min(
Math.abs(Math.hypot(p.x - 20, p.y - 20) - 15),
Math.abs(Math.hypot(p.x - 40, p.y - 40) - 15),
);
expect(off).toBeLessThan(knots.has(`${p.x},${p.y}`) ? FLATTEN_TOLERANCE : 0.05);
}
});
});
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(allShapes(f.art()).map((s) => s.id)).toEqual([first, low]);
const result = allShapes(f.art())[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(allShapes(f.art()));
f.stack.execute(new CombineVectorShapesCommand(f.assetId, f.ids, 'union'));
expect(allShapes(f.art())).toHaveLength(1);
f.stack.undo();
expect(JSON.stringify(allShapes(f.art()))).toBe(before);
f.stack.redo();
expect(paintedArea(allShapes(f.art())[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(allShapes(f.art()));
expect(() => f.stack.execute(new CombineVectorShapesCommand(f.assetId, f.ids, 'intersect'))).toThrow(
/nothing in common/,
);
expect(JSON.stringify(allShapes(f.art()))).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 = allShapes(f.art()).map((s) => s.id);
f.stack.execute(new RemoveVectorShapesCommand(f.assetId, [f.ids[0]!, f.ids[2]!]));
expect(allShapes(f.art()).map((s) => s.id)).toEqual([f.ids[1]]);
f.stack.undo();
expect(allShapes(f.art()).map((s) => s.id)).toEqual(before);
});
});