Files
feuerwerk/tests/core/polygon.test.ts
T
adminandClaude Opus 5.5 963f8a152a Open a scene framed, and thin out two inspector sections (#295)
Three small things asked for in one issue. A scene came up wherever
the view happened to stand, and the inspector carried a button and a
list that are better reached somewhere else.

- A scene comes up framed as if Fit had been clicked: after a
  project is loaded and when another scene is opened. From then on
  the view belongs to the user.
- An empty scene has nothing to frame and stands at the origin, one
  pixel per unit. A scene the running game loads is not framed anew.
- The Container tab is left out. It keeps the view of each container
  as before (#171).
- The Unpack button and the two lines under it are gone from the
  inspector of a placed container. Unpack container stays in the
  right-click menu of the hierarchy and of the scene view.
- The corners of a polygon collider are no row in the inspector any
  more. They are edited on the outline in the scene view. The new
  schema option noRow keeps a property out of the inspector while it
  stays a property.

Tests:
- Gate on melektaus-atx: 219 files, 3401 tests, CHECK EXIT 0.
- One new unit test in tests/core/polygon.test.ts: the corners are
  a property of the collider and no row.
- Three new e2e checks: a loaded project is framed like Fit (module
  03), an opened scene is (04), and after a real reload a click on
  Fit moves nothing (09).
- Module 09 unpacks through the right-click menu now and checks that
  the inspector offers no button.
- Full suite on melektaus-atx: 1695 checks, all pass. Smokes 24, 19
  and 21, all pass.
- No counter-probe evaluated, and nothing looked at after the build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:52:49 +02:00

343 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 { 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));
}
}
});
});