A frame's collision outline can be generated from its pixels (#161)
game-maker / gate (push) Successful in 2m13s
game-maker / suite (push) Successful in 7m42s

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>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-21 08:00:30 +02:00
1 parent f4ec23d3d3
commit 2d79230b55
10 files changed
+705 -5

No files matched your search

+191
View File
@@ -0,0 +1,191 @@
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);
});
});