267 lines
13 KiB
TypeScript
267 lines
13 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { transformMatrix, translation } from '../../src/core/model/matrix';
|
||
import {
|
||
cleanOutline,
|
||
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));
|
||
});
|
||
});
|