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