Files
feuerwerk/tests/core/polygon.test.ts
T

334 lines
16 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.
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 { 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));
});
});
/** 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));
}
}
});
});