Files
feuerwerk/tests/core/trace-collision.test.ts
adminandClaude Opus 5 2d79230b55
game-maker / gate (push) Successful in 2m13s
game-maker / suite (push) Successful in 7m42s
A frame's collision outline can be generated from its pixels (#161)
Drawing an outline by hand for every frame of a sheet is slow, and the
outline mostly follows the opaque part of the image anyway. The texture
tab now traces it: one click, a corner budget, and every Frame
collision collider and tile using that frame blocks with the result.

- "Generate collision..." sits in the texture tab header and is off
  while the texture has no frames.
- The dialog has Max points (default 8, 3 to 64), Solid from ... %
  alpha (default 50) and All frames of the texture. A live line says
  what Generate will write, and Generate stays off when nothing is
  solid enough.
- traceCollision walks each island of solid pixels along the pixel
  edges, then drops the least important corners until at most N are
  left and no stair step remains. A slope becomes one straight edge.
- An outline never crosses itself. Where the image would force that,
  the island's convex hull is taken instead.
- Crumbs under 1 % of the frame area are dropped unless they are the
  only island. Holes are not reproduced.
- Existing outlines are replaced. One frame or all frames is a single
  undo step.
- TextureLibrary hands out the sheet's RGBA data, and a pure function
  cuts a frame's region from it.

Findings:
- The wedge in the e2e fixture gets 4 corners, not 3. Its tip is one
  pixel wide in the image, and that real edge stays while corners are
  still free.

Tests:
- ./check.sh: 131 files, 1941 tests, CHECK EXIT 0 (was 130 / 1931).
- 10 new unit tests in tests/core/trace-collision.test.ts, including
  150 random shapes that must all be valid and within the budget.
- GM_STEPS=42, freshly seeded: ALL PASSED, 20 checks (was 16).
- Three counter-probes bite: no hull fallback, no stair threshold,
  dialog ignoring the alpha threshold.
- Check sum 1202 in README is computed (1198 + 4), CI measures it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 08:00:30 +02:00

192 lines
7.2 KiB
TypeScript
Raw Permalink 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 { CreateTextureCommand } from '../../src/core/commands/assetCommands';
import { AddFrameCommand, SetFramesCollisionCommand } from '../../src/core/commands/frameCommands';
import type { TextureAsset } from '../../src/core/model/Asset';
import { polygonProblem, signedArea } from '../../src/core/model/polygon';
import { findAsset } from '../../src/core/model/Project';
import type { Vec2 } from '../../src/core/model/Properties';
import { alphaOfRect, DEFAULT_TRACE, traceOutlines } from '../../src/core/model/traceCollision';
import { makeProject } from '../helpers';
const SIZE = 32;
/** A picture of SIZE × SIZE whose opacity at each pixel centre is `at(x, y)`. */
function picture(at: (x: number, y: number) => number, size = SIZE): number[] {
const alpha: number[] = [];
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) alpha.push(at(x + 0.5, y + 0.5));
return alpha;
}
const corners = (ring: readonly Vec2[]) => ring.map((p) => `${p.x},${p.y}`).sort();
const area = (ring: readonly Vec2[]) => Math.abs(signedArea(ring));
describe('collision outlines traced from a picture (#161)', () => {
it('outlines an opaque frame with its four corners', () => {
const rings = traceOutlines(
picture(() => 255),
SIZE,
SIZE,
DEFAULT_TRACE,
);
expect(rings).toHaveLength(1);
expect(corners(rings[0]!)).toEqual(
corners([
{ x: 0, y: 0 },
{ x: 32, y: 0 },
{ x: 32, y: 32 },
{ x: 0, y: 32 },
]),
);
});
it('gives nothing for a frame without a solid pixel', () => {
expect(
traceOutlines(
picture(() => 0),
SIZE,
SIZE,
DEFAULT_TRACE,
),
).toEqual([]);
});
it('straightens the stairs of a slope into one edge', () => {
const wedge = traceOutlines(
picture((x, y) => (x + y > SIZE ? 255 : 0)),
SIZE,
SIZE,
DEFAULT_TRACE,
);
expect(wedge).toHaveLength(1);
expect(wedge[0]!.length).toBeLessThanOrEqual(4);
expect(polygonProblem(wedge[0]!)).toBe('');
expect(Math.abs(area(wedge[0]!) - 512) / 512).toBeLessThan(0.05);
});
it('keeps at most the corners asked for, and the shape stays close to the picture', () => {
const round = picture((x, y) => (Math.hypot(x - 16, y - 16) <= 12 ? 255 : 0));
const eight = traceOutlines(round, SIZE, SIZE, DEFAULT_TRACE)[0]!;
const four = traceOutlines(round, SIZE, SIZE, { ...DEFAULT_TRACE, maxPoints: 4 })[0]!;
const many = traceOutlines(round, SIZE, SIZE, { ...DEFAULT_TRACE, maxPoints: 32 })[0]!;
expect([eight.length, four.length]).toEqual([8, 4]);
expect(many.length).toBeGreaterThan(8);
expect(many.length).toBeLessThanOrEqual(32);
const disc = Math.PI * 12 * 12;
expect(Math.abs(area(eight) - disc) / disc).toBeLessThan(0.1);
expect(Math.abs(area(many) - disc) / disc).toBeLessThan(Math.abs(area(eight) - disc) / disc);
for (const ring of [eight, four, many]) expect(polygonProblem(ring)).toBe('');
});
it('takes pixels from the threshold on as solid', () => {
// An opaque square with a faint band (alpha 100 of 255, 39 %) to its right.
const alpha = picture((x, y) => (y > 8 && y < 24 ? (x < 16 ? 255 : x < 24 ? 100 : 0) : 0));
const width = (threshold: number) => {
const rings = traceOutlines(alpha, SIZE, SIZE, { ...DEFAULT_TRACE, threshold });
return Math.max(...rings[0]!.map((p) => p.x)) - Math.min(...rings[0]!.map((p) => p.x));
};
expect(width(0.5)).toBe(16);
expect(width(0.3)).toBe(24);
expect(width(0.39)).toBe(24);
expect(width(0.4)).toBe(16);
});
it('outlines each island, leaves crumbs out and keeps the only crumb there is', () => {
const islands = picture((x, y) =>
(x < 10 && y < 10) || (x > 20 && y > 20) || (Math.floor(x) === 30 && Math.floor(y) === 2) ? 255 : 0,
);
const rings = traceOutlines(islands, SIZE, SIZE, DEFAULT_TRACE);
expect(rings.map(corners)).toEqual([
corners([
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 10, y: 10 },
{ x: 0, y: 10 },
]),
corners([
{ x: 20, y: 20 },
{ x: 32, y: 20 },
{ x: 32, y: 32 },
{ x: 20, y: 32 },
]),
]);
const crumb = picture((x, y) => (Math.floor(x) === 30 && Math.floor(y) === 2 ? 255 : 0));
expect(traceOutlines(crumb, SIZE, SIZE, DEFAULT_TRACE)).toHaveLength(1);
});
it('outlines a ring by its outside and leaves the hole solid', () => {
const ring = picture((x, y) =>
Math.hypot(x - 16, y - 16) <= 14 && Math.hypot(x - 16, y - 16) > 6 ? 255 : 0,
);
const rings = traceOutlines(ring, SIZE, SIZE, DEFAULT_TRACE);
expect(rings).toHaveLength(1);
expect(area(rings[0]!)).toBeGreaterThan(Math.PI * 13 * 13);
});
it('gives a valid outline within the corner budget for any blob, pinched ones included', () => {
let seed = 7;
const random = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
for (let run = 0; run < 150; run++) {
const cells = Array.from({ length: 16 }, () => random() < 0.55);
const alpha = picture((x, y) => (cells[Math.floor(y / 4) * 4 + Math.floor(x / 4)] ? 255 : 0), 16);
const maxPoints = 3 + (run % 10);
for (const outline of traceOutlines(alpha, 16, 16, { ...DEFAULT_TRACE, maxPoints })) {
expect(outline.length).toBeLessThanOrEqual(maxPoints);
expect(polygonProblem(outline)).toBe('');
}
}
});
});
describe('a frame cut out of the sheet for tracing', () => {
it('reads the opacity of the frame’s pixels and leaves what lies past the sheet clear', () => {
// A 3 × 2 sheet whose alpha is 10 · (index + 1).
const rgba = Array.from({ length: 6 }, (_, i) => [0, 0, 0, 10 * (i + 1)]).flat();
expect([...alphaOfRect(rgba, 3, 2, { x: 1, y: 0, w: 2, h: 2 })]).toEqual([20, 30, 50, 60]);
expect([...alphaOfRect(rgba, 3, 2, { x: 2, y: 1, w: 2, h: 2 })]).toEqual([60, 0, 0, 0]);
});
});
describe('SetFramesCollisionCommand', () => {
it('replaces the outlines of several frames in one undo step, and undo gives back what was there', () => {
const fixture = makeProject();
const create = new CreateTextureCommand('Sheet', {
file: `${'ab'.repeat(32)}.png`,
width: 64,
height: 32,
});
fixture.stack.execute(create);
const add = (x: number) => {
const command = new AddFrameCommand(create.asset.id, { x, y: 0, w: 32, h: 32 });
fixture.stack.execute(command);
return command.frameId;
};
const [left, right] = [add(0), add(32)];
const frames = () => (findAsset(fixture.project, create.asset.id) as TextureAsset).frames;
const old = [
{ x: 0, y: 0 },
{ x: 4, y: 0 },
{ x: 0, y: 4 },
];
frames()[0]!.collision = [old];
const square = [
{ x: 0, y: 0 },
{ x: 32, y: 0 },
{ x: 32, y: 32 },
{ x: 0, y: 32 },
];
fixture.stack.execute(
new SetFramesCollisionCommand(
create.asset.id,
new Map([
[left, [square]],
[right, [square, old]],
]),
),
);
expect(frames().map((f) => f.collision)).toEqual([[square], [square, old]]);
fixture.stack.undo();
expect(frames()[0]!.collision).toEqual([old]);
expect('collision' in frames()[1]!).toBe(false);
});
});