import { describe, expect, it } from 'vitest'; import { transformMatrix, translation } from '../../src/core/model/matrix'; import { boundaryOf, cleanOutline, clipToRect, convexPieces, isConvex, pointsOf, polygonProblem, signedArea, } from '../../src/core/model/polygon'; import type { Vec2 } from '../../src/core/model/Properties'; import { type ColliderShape, colliderShape, colliderSize, contactBetween, exitDistance, shapeArea, shapeContains, shapesOverlap, } from '../../src/core/properties/collider'; import { defaultsFor, isRowShown, schemaFor } from '../../src/core/properties/PropertySchema'; import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../../src/editor/polygonEdit'; const P = (...xy: number[]) => Array.from({ length: xy.length / 2 }, (_, i) => ({ x: xy[2 * i]!, y: xy[2 * i + 1]! })); const area = (points: Vec2[]) => Math.abs(signedArea(points)); /** An L: 3 wide at the bottom, 1 wide going up – concave at (1, 1). Area 3 + 2 = 5. */ const L = P(0, 0, 1, 0, 1, 2, 3, 2, 3, 3, 0, 3); let seed = 11; const random = () => (seed = (seed * 48271) % 2147483647) / 2147483647; /** A star-shaped outline: corners round a centre at random radii, so it is simple and mostly concave. */ function randomStar(corners: number): Vec2[] { return Array.from({ length: corners }, (_, i) => { const angle = (i / corners) * 2 * Math.PI; const r = 20 + random() * 80; return { x: Math.cos(angle) * r, y: Math.sin(angle) * r }; }); } describe('polygon outlines (#151)', () => { it('reads {x, y} items and leaves out what is no point', () => { expect( pointsOf([ { x: 1, y: 2 }, { x: 'a', y: 3 }, 5, { x: 4 }, { x: '6', y: 7 }, { x: '', y: 1 }, { x: true, y: 1 }, ]), ).toEqual([ { x: 1, y: 2 }, { x: 6, y: 7 }, ]); expect(pointsOf(undefined)).toEqual([]); expect(pointsOf('nope')).toEqual([]); }); it('says what is wrong: too few corners, no area, an outline that crosses itself', () => { expect(polygonProblem(P(0, 0, 10, 0))).toMatch(/three corners/); expect(polygonProblem(P(0, 0, 10, 0, 20, 0))).toMatch(/three corners/); // straight: cleaned to two expect(polygonProblem(P(0, 0, 10, 0, 10, 10, 10, 10, 0, 10))).toBe(''); // a repeated corner is fine expect(polygonProblem(P(0, 0, 10, 10, 10, 0, 0, 10))).toMatch(/no area/); // a bow tie: halves cancel // A pentagram: every other corner of a pentagon, so each edge crosses two others. const pentagram = [0, 2, 4, 1, 3].map((i) => ({ x: Math.cos((i * 2 * Math.PI) / 5) * 50, y: Math.sin((i * 2 * Math.PI) / 5) * 50, })); expect(polygonProblem(pentagram)).toMatch(/crosses itself/); // Touching itself at a corner counts as crossing: the two halves would share only a point. expect(polygonProblem(P(0, 0, 10, 0, 5, 5, 10, 10, 0, 10, 5, 5))).toMatch(/crosses itself/); expect(polygonProblem(L)).toBe(''); expect(cleanOutline(P(0, 0, 5, 0, 10, 0, 10, 10))).toEqual(P(0, 0, 10, 0, 10, 10)); }); it('a convex outline is one piece, in either winding; one with a problem has none', () => { const square = P(0, 0, 10, 0, 10, 10, 0, 10); expect(convexPieces(square)).toEqual([square]); expect(convexPieces([...square].reverse())).toHaveLength(1); expect(convexPieces(P(0, 0, 10, 10, 10, 0, 0, 10))).toEqual([]); }); it('an L is cut into two convex pieces that cover it exactly', () => { for (const outline of [L, [...L].reverse()]) { const pieces = convexPieces(outline); expect(pieces).toHaveLength(2); expect(pieces.every(isConvex)).toBe(true); expect(pieces.reduce((sum, p) => sum + area(p), 0)).toBeCloseTo(5, 12); } }); it('random star outlines: convex pieces, the whole area, only the outline’s own corners, few pieces', () => { for (let round = 0; round < 200; round++) { const outline = randomStar(5 + Math.floor(random() * 12)); const pieces = convexPieces(outline); expect(pieces.length).toBeGreaterThan(0); expect(pieces.every(isConvex)).toBe(true); expect(pieces.reduce((sum, p) => sum + area(p), 0)).toBeCloseTo(area(outline), 6); const corners = new Set(outline.map((p) => `${p.x},${p.y}`)); expect(pieces.flat().every((p) => corners.has(`${p.x},${p.y}`))).toBe(true); // Triangles alone would be n − 2; merging must have joined some whenever there was room. expect(pieces.length).toBeLessThanOrEqual(outline.length - 2); } }); }); const box = (x: number, y: number, w: number, h: number, degrees = 0): ColliderShape => ({ kind: 'box', x, y, w, h, angle: (degrees * Math.PI) / 180, }); const circle = (x: number, y: number, r: number): ColliderShape => ({ kind: 'circle', x, y, r }); /** The same box as a polygon collider, so the new path can be held against the old exact one. */ const boxAsPolygon = (x: number, y: number, w: number, h: number, degrees = 0): ColliderShape => colliderShape( { shape: 'polygon', points: P(-w / 2, -h / 2, w / 2, -h / 2, w / 2, h / 2, -w / 2, h / 2) }, transformMatrix(x, y, degrees, 1, false, false), ); describe('collider geometry of capsules and polygons (#151)', () => { it('a capsule fills width × height, rounded along the longer side, and turns with the object', () => { expect(colliderShape({ shape: 'capsule', width: 20, height: 60 }, translation(5, 0))).toMatchObject({ kind: 'capsule', a: { x: 5, y: -20 }, b: { x: 5, y: 20 }, r: 10, }); expect(colliderShape({ shape: 'capsule', width: 60, height: 20 }, translation(0, 0))).toMatchObject({ a: { x: -20, y: 0 }, b: { x: 20, y: 0 }, r: 10, }); const turned = colliderShape( { shape: 'capsule', width: 20, height: 60 }, transformMatrix(0, 0, 90, 2, false, false), ); expect(turned.kind === 'capsule' && turned.a.x).toBeCloseTo(40, 9); expect(turned.kind === 'capsule' && turned.r).toBe(20); // As wide as tall: no segment left, a circle. const round = colliderShape({ shape: 'capsule', width: 30, height: 30 }, translation(0, 0)); expect(round).toMatchObject({ a: { x: 0, y: 0 }, b: { x: 0, y: 0 }, r: 15 }); expect(shapeArea(round)).toBeCloseTo(Math.PI * 225, 9); }); it('a polygon keeps its corners in its own units: moved, sized, turned and mirrored with the object', () => { const props = { shape: 'polygon', points: P(0, -10, 10, 10, -10, 10) }; expect(colliderShape(props, translation(100, 0))).toMatchObject({ outline: P(100, -10, 110, 10, 90, 10), pieces: [P(100, -10, 110, 10, 90, 10)], }); const mirrored = colliderShape( { shape: 'polygon', points: P(0, 0, 10, 0, 0, 10) }, transformMatrix(0, 0, 0, 2, true, false), ); expect(mirrored.kind === 'polygon' && mirrored.outline).toEqual(P(0, 0, -20, 0, 0, 20)); expect(colliderSize(props)).toEqual({ w: 20, h: 20 }); // Without points of its own it is the default triangle. expect(colliderShape({ shape: 'polygon' }, translation(0, 0))).toMatchObject({ outline: P(0, -50, 50, 50, -50, 50), }); // One that crosses itself is drawn but has no pieces, so it meets nothing. const bowTie = colliderShape( { shape: 'polygon', points: P(0, 0, 10, 10, 10, 0, 0, 10) }, translation(0, 0), ); expect(bowTie).toMatchObject({ pieces: [] }); expect(shapesOverlap(bowTie, box(5, 5, 100, 100))).toBe(false); expect(contactBetween(bowTie, box(5, 5, 100, 100), { x: 1, y: 0 }).separation).toBe(Infinity); }); it('a square polygon meets boxes and circles exactly as the box it stands for', () => { for (let round = 0; round < 400; round++) { const [x, y, deg] = [random() * 160 - 80, random() * 160 - 80, random() * 360]; const [w, h] = [20 + random() * 60, 20 + random() * 60]; const other = random() < 0.5 ? box(0, 0, 50, 70, random() * 90) : circle(0, 0, 10 + random() * 40); const classic = box(x, y, w, h, deg); const polygon = boxAsPolygon(x, y, w, h, deg); const travel = { x: random() - 0.5, y: random() - 0.5 }; expect(shapesOverlap(polygon, other)).toBe(shapesOverlap(classic, other)); const want = contactBetween(classic, other, travel); const got = contactBetween(polygon, other, travel); // Apart, box against box measures along its best axis rather than the real distance. if (other.kind === 'circle' || want.separation <= 0) { expect(got.separation).toBeCloseTo(want.separation, 9); expect(got.normal.x).toBeCloseTo(want.normal.x, 9); expect(got.normal.y).toBeCloseTo(want.normal.y, 9); } expect(exitDistance(polygon, other, travel)).toBeCloseTo(exitDistance(classic, other, travel), 7); } }); it('a capsule standing 10 above a floor, and sunk 5 into it: the gap, the normal and the way out', () => { const floor = box(0, 100, 400, 40); // top face at 80 const above = colliderShape({ shape: 'capsule', width: 20, height: 60 }, translation(0, 40)); // bottom at 70 expect(contactBetween(above, floor, { x: 0, y: 1 })).toEqual({ separation: 10, normal: { x: 0, y: -1 } }); const sunk = colliderShape({ shape: 'capsule', width: 20, height: 60 }, translation(0, 55)); expect(shapesOverlap(sunk, floor)).toBe(true); expect(contactBetween(sunk, floor, { x: 0, y: 1 }).separation).toBeCloseTo(-5, 12); expect(exitDistance(sunk, floor, { x: 0, y: -1 })).toBeCloseTo(5, 9); // On its side against a wall, the round end is what touches. const side = colliderShape({ shape: 'capsule', width: 60, height: 20 }, translation(0, 0)); const wall = box(40, 0, 20, 200); // left face at 30 expect(shapesOverlap(side, wall)).toBe(false); expect(shapesOverlap(side, box(39, 0, 20, 200))).toBe(true); expect(shapeContains(side, { x: 29, y: 0 })).toBe(true); expect(shapeContains(side, { x: 29, y: 9 })).toBe(false); // outside the rounding }); it('a circle in line with a capsule, beyond its end: apart by the real distance, not by the side axis', () => { const capsule = colliderShape({ shape: 'capsule', width: 60, height: 20 }, translation(0, 0)); // ends at ±30 expect(contactBetween(circle(45, 0, 10), capsule, { x: -1, y: 0 })).toEqual({ separation: 5, normal: { x: 1, y: 0 }, }); expect(shapesOverlap(circle(45, 0, 10), capsule)).toBe(false); expect(shapesOverlap(circle(35, 0, 10), capsule)).toBe(true); // Two capsules end to end on one line. const next = colliderShape({ shape: 'capsule', width: 60, height: 20 }, translation(65, 0)); expect(contactBetween(next, capsule, { x: -1, y: 0 }).separation).toBeCloseTo(5, 12); }); it('a concave polygon: a box in the notch of a U touches nothing, one on its rim does', () => { // A U 120 wide, 100 tall, walls 20 thick: the notch is 80 wide and 80 deep. const u = colliderShape( { shape: 'polygon', points: P(-60, -50, -40, -50, -40, 30, 40, 30, 40, -50, 60, -50, 60, 50, -60, 50) }, translation(0, 0), ); expect(u.kind === 'polygon' && u.pieces.length).toBeGreaterThan(1); expect(shapesOverlap(box(0, -10, 78, 78), u)).toBe(false); // inside the notch, 1 clear of every face expect(shapesOverlap(box(0, -10, 82, 60), u)).toBe(true); // wider than the notch // A box resting on the inner floor (top face at 30): touching, not overlapping; its way out is up. const resting = box(0, 10, 40, 40); expect(shapesOverlap(resting, u)).toBe(false); expect(contactBetween(resting, u, { x: 0, y: 1 }).normal).toEqual({ x: 0, y: -1 }); expect(exitDistance(box(0, 13, 40, 40), u, { x: 0, y: -1 })).toBeCloseTo(3, 9); }); }); describe('editing an outline by its corners', () => { const square = P(0, 0, 100, 0, 100, 100, 0, 100); it('a corner wins over the edges through it; an edge gives the point on it', () => { expect(gripAt(square, { x: 3, y: 2 }, 5)).toEqual({ kind: 'corner', index: 0 }); expect(gripAt(square, { x: 50, y: 3 }, 5)).toEqual({ kind: 'edge', index: 0, at: { x: 50, y: 0 } }); expect(gripAt(square, { x: 3, y: 50 }, 5)).toEqual({ kind: 'edge', index: 3, at: { x: 0, y: 50 } }); expect(gripAt(square, { x: 50, y: 50 }, 5)).toBeNull(); // Two corners are no outline yet: only the corners can be taken. expect(gripAt(P(0, 0, 100, 0), { x: 50, y: 0 }, 5)).toBeNull(); }); it('puts, moves and takes away corners; the last three stay unless told otherwise', () => { expect(withCornerAfter(square, 3, { x: 0, y: 50 })).toEqual([...square, { x: 0, y: 50 }]); expect(withCornerAfter(square, 0, { x: 50, y: 0 })[1]).toEqual({ x: 50, y: 0 }); expect(withCornerMoved(square, 2, { x: 90, y: 80 })[2]).toEqual({ x: 90, y: 80 }); expect(withoutCorner(square, 1)).toEqual(P(0, 0, 100, 100, 0, 100)); expect(withoutCorner(P(0, 0, 1, 0, 0, 1), 0)).toBeNull(); expect(withoutCorner(P(0, 0, 1, 0), 0, 0)).toEqual(P(1, 0)); }); it('the corners are a property of the collider, but no row in the inspector (#295)', () => { const collider = schemaFor('collider'); expect(collider.map((d) => d.key)).toContain('points'); expect(defaultsFor('collider').points).toHaveLength(3); for (const shape of ['box', 'polygon']) expect(collider.filter((d) => isRowShown(d, { shape })).map((d) => d.key)).not.toContain('points'); }); }); /** A ring as a set of corners, whichever corner it starts at and whichever way it runs. */ const corners = (ring: Vec2[]) => ring.map((p) => `${+p.x.toFixed(6)},${+p.y.toFixed(6)}`).sort(); describe('the outer edges of rings side by side (#173)', () => { it('two squares side by side are one rectangle, whichever way each runs', () => { const lines = boundaryOf([P(0, 0, 1, 0, 1, 1, 0, 1), P(1, 0, 1, 1, 2, 1, 2, 0)]); expect(lines).toHaveLength(1); expect(lines[0]!.closed).toBe(true); expect(corners(lines[0]!.points)).toEqual(corners(P(0, 0, 2, 0, 2, 1, 0, 1))); }); it('a long edge meeting several short ones drops only where they meet', () => { // A 3-wide bar with two unit squares on top of its left two cells: an L lying down. const lines = boundaryOf([ P(0, 1, 3, 1, 3, 2, 0, 2), P(0, 0, 1, 0, 1, 1, 0, 1), P(1, 0, 2, 0, 2, 1, 1, 1), ]); expect(lines).toHaveLength(1); expect(corners(lines[0]!.points)).toEqual(corners(P(0, 0, 2, 0, 2, 1, 3, 1, 3, 2, 0, 2))); }); it('the same ring twice is drawn once', () => { const lines = boundaryOf([P(0, 0, 1, 0, 1, 1, 0, 1), P(0, 0, 0, 1, 1, 1, 1, 0)]); expect(lines).toHaveLength(1); expect(corners(lines[0]!.points)).toEqual(corners(P(0, 0, 1, 0, 1, 1, 0, 1))); }); it('rings apart, or overlapping, keep all their edges', () => { expect(boundaryOf([P(0, 0, 1, 0, 1, 1, 0, 1), P(3, 0, 4, 0, 4, 1, 3, 1)])).toHaveLength(2); const lines = boundaryOf([P(0, 0, 2, 0, 2, 2, 0, 2), P(1, 1, 3, 1, 3, 3, 1, 3)]); expect(lines.map((line) => line.points.length)).toEqual([4, 4]); }); it('the stretch where a clipped concave outline joins itself along the cut is left out', () => { // A U cut below its bottom: two arms, joined by `clipToRect` along the top edge of the cut. const cut = clipToRect(P(0, 0, 1, 0, 1, 2, 2, 2, 2, 0, 3, 0, 3, 3, 0, 3), -1, -1, 4, 1); const lines = boundaryOf([cut]); expect(lines.map((line) => line.closed)).toEqual([true, true]); expect(lines.map((line) => corners(line.points)).sort()).toEqual( [corners(P(0, 0, 1, 0, 1, 1, 0, 1)), corners(P(2, 0, 3, 0, 3, 1, 2, 1))].sort(), ); }); it('a star cut into convex pieces, or along a grid, comes back as its own outline', () => { for (let n = 0; n < 50; n++) { const star = cleanOutline(randomStar(6 + (n % 10))); const pieces = convexPieces(star); const cells: Vec2[][] = []; for (let x = -100; x < 100; x += 25) for (let y = -100; y < 100; y += 25) for (const piece of pieces) { const part = clipToRect(piece, x, y, x + 25, y + 25); if (part.length > 0) cells.push(part); } for (const rings of [pieces, cells]) { const lines = boundaryOf(rings); expect(lines).toHaveLength(1); expect(lines[0]!.closed).toBe(true); expect(corners(lines[0]!.points)).toEqual(corners(star)); } } }); });