/** * 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 { 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(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); }); });