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>
This commit is contained in:
1 parent
f4ec23d3d3
commit
2d79230b55
10 files changed
+705
-5
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user