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