From 2d79230b556c3cfe8b6b968da17fe5977574aaca Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Mon, 21 Sep 2026 08:00:30 +0200 Subject: [PATCH] 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 --- README.md | 2 +- src/core/commands/frameCommands.ts | 27 +++ src/core/model/polygon.ts | 2 +- src/core/model/traceCollision.ts | 204 ++++++++++++++++++ src/editor/debugHook.ts | 2 + src/editor/texture/GenerateCollisionDialog.ts | 125 +++++++++++ src/editor/texture/TextureEditor.ts | 29 ++- src/viewport/TextureLibrary.ts | 15 ++ tests/core/trace-collision.test.ts | 191 ++++++++++++++++ tools/e2e/42-shapes.mjs | 113 +++++++++- 10 files changed, 705 insertions(+), 5 deletions(-) create mode 100644 src/core/model/traceCollision.ts create mode 100644 src/editor/texture/GenerateCollisionDialog.ts create mode 100644 tests/core/trace-collision.test.ts diff --git a/README.md b/README.md index 58574558..f483b7a1 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1198 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1202 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/frameCommands.ts b/src/core/commands/frameCommands.ts index 07026230..9851dcdc 100644 --- a/src/core/commands/frameCommands.ts +++ b/src/core/commands/frameCommands.ts @@ -150,6 +150,33 @@ export class SetFrameCollisionCommand implements Command { } } +/** + * Replaces the outlines of several frames at once – what "Generate collision…" writes (#161), one + * undo step whether it covered one frame or the whole sheet. + */ +export class SetFramesCollisionCommand implements Command { + readonly label = 'Generate collision'; + private previous = new Map(); + + constructor( + private readonly assetId: string, + private readonly outlines: ReadonlyMap, + ) {} + + execute(project: Project): void { + const asset = requireTexture(project, this.assetId); + if (this.outlines.size === 0) throw new CommandError('No frame to generate a collision outline for'); + const frames = [...this.outlines.keys()].map((id) => requireFrame(asset, id)); + this.previous = new Map(frames.map((frame) => [frame.id, frameCollision(frame)])); + for (const frame of frames) setFrameCollision(frame, this.outlines.get(frame.id)!); + } + + undo(project: Project): void { + const asset = requireTexture(project, this.assetId); + for (const [id, outlines] of this.previous) setFrameCollision(requireFrame(asset, id), outlines); + } +} + export class RenameFrameCommand implements Command { readonly label = 'Rename frame'; private previous = ''; diff --git a/src/core/model/polygon.ts b/src/core/model/polygon.ts index b7ee7c68..aefaa912 100644 --- a/src/core/model/polygon.ts +++ b/src/core/model/polygon.ts @@ -67,7 +67,7 @@ export function cleanOutline(points: readonly Vec2[]): Vec2[] { } /** Whether the open segments ab and cd cross or touch. */ -function segmentsMeet(a: Vec2, b: Vec2, c: Vec2, d: Vec2): boolean { +export function segmentsMeet(a: Vec2, b: Vec2, c: Vec2, d: Vec2): boolean { const d1 = cross(c, d, a); const d2 = cross(c, d, b); const d3 = cross(a, b, c); diff --git a/src/core/model/traceCollision.ts b/src/core/model/traceCollision.ts new file mode 100644 index 00000000..bb9c830c --- /dev/null +++ b/src/core/model/traceCollision.ts @@ -0,0 +1,204 @@ +/** + * A frame's collision outlines read off its picture (#161): the pixels at least as opaque as the + * threshold are solid, every island of them is walked round along the pixel edges, and the walk is + * cut down to a few corners. Holes are not traced – an outline blocks with all it encloses. + */ +import { polygonProblem, segmentsMeet } from './polygon'; +import type { Vec2 } from './Properties'; + +export interface TraceOptions { + /** The most corners one outline keeps. */ + maxPoints: number; + /** Opacity from which a pixel is solid, 0 … 1. */ + threshold: number; +} + +export const DEFAULT_TRACE: TraceOptions = { maxPoints: 8, threshold: 0.5 }; +export const MIN_TRACE_POINTS = 3; +export const MAX_TRACE_POINTS = 64; + +/** An island below this share of the frame is a crumb and gets no outline – unless it is the largest. */ +const CRUMB_SHARE = 0.01; +/** A corner making a triangle this small (px²) with its neighbours is a step of the pixel grid. */ +const STEP_AREA = 1; + +/** + * The outlines of the solid islands, in the frame's own pixels. `alpha` holds one opacity 0 … 255 + * per pixel, row by row. Islands are 4-connected: pixels meeting at a corner only are two islands. + */ +export function traceOutlines( + alpha: ArrayLike, + width: number, + height: number, + options: TraceOptions, +): Vec2[][] { + const w = Math.floor(width); + const h = Math.floor(height); + if (w <= 0 || h <= 0 || alpha.length < w * h) return []; + const threshold = Math.min(1, Math.max(0, Number.isFinite(options.threshold) ? options.threshold : 0.5)); + const cut = Math.max(1, Math.ceil(threshold * 255 - 1e-9)); + const solid = new Uint8Array(w * h); + for (let i = 0; i < w * h; i++) solid[i] = alpha[i]! >= cut ? 1 : 0; + + const islands = islandsOf(solid, w, h); + if (islands.length === 0) return []; + const largest = Math.max(...islands.map((island) => island.area)); + const maxPoints = clampPoints(options.maxPoints); + return islands + .filter((island) => island.area === largest || island.area >= CRUMB_SHARE * w * h) + .map((island) => { + const walk = walkRound(solid, w, h, island.start % w, Math.floor(island.start / w)); + const outline = simplify(walk, maxPoints); + const kept = outline.length <= maxPoints && !polygonProblem(outline); + return kept ? outline : simplify(convexHull(walk), maxPoints); + }); +} + +/** The opacities of `rect` out of RGBA bytes of a picture `sheetWidth` wide; outside it is clear. */ +export function alphaOfRect( + rgba: ArrayLike, + sheetWidth: number, + sheetHeight: number, + rect: { x: number; y: number; w: number; h: number }, +): Uint8Array { + const alpha = new Uint8Array(rect.w * rect.h); + for (let y = 0; y < rect.h; y++) + for (let x = 0; x < rect.w; x++) { + const sx = rect.x + x; + const sy = rect.y + y; + if (sx >= 0 && sy >= 0 && sx < sheetWidth && sy < sheetHeight) + alpha[y * rect.w + x] = rgba[(sy * sheetWidth + sx) * 4 + 3]!; + } + return alpha; +} + +export function clampPoints(value: number): number { + const n = Math.round(Number.isFinite(value) ? value : DEFAULT_TRACE.maxPoints); + return Math.min(MAX_TRACE_POINTS, Math.max(MIN_TRACE_POINTS, n)); +} + +interface Island { + /** Index of its first pixel in reading order – the top-left corner of that pixel is on its rim. */ + start: number; + area: number; +} + +function islandsOf(solid: Uint8Array, w: number, h: number): Island[] { + const seen = new Uint8Array(w * h); + const islands: Island[] = []; + const stack: number[] = []; + for (let start = 0; start < w * h; start++) { + if (!solid[start] || seen[start]) continue; + seen[start] = 1; + stack.push(start); + let area = 0; + while (stack.length > 0) { + const i = stack.pop()!; + area++; + const x = i % w; + for (const j of [x > 0 ? i - 1 : -1, x < w - 1 ? i + 1 : -1, i - w, i + w]) + if (j >= 0 && j < w * h && solid[j] && !seen[j]) { + seen[j] = 1; + stack.push(j); + } + } + islands.push({ start, area }); + } + return islands; +} + +/** + * The rim of the island whose first pixel is (sx, sy), as the corners where it turns, walked with + * the island on the right – clockwise on screen. Where two pixels meet at a corner only, the walk + * turns away from the diagonal one: it belongs to another island. + */ +function walkRound(solid: Uint8Array, w: number, h: number, sx: number, sy: number): Vec2[] { + const inside = (x: number, y: number) => x >= 0 && y >= 0 && x < w && y < h && solid[y * w + x] === 1; + const corners: Vec2[] = []; + let [x, y, dx, dy] = [sx, sy, 1, 0]; + for (let guard = 4 * (w + 1) * (h + 1); guard > 0; guard--) { + x += dx; + y += dy; + const [rx, ry] = [-dy, dx]; + const right = inside(Math.floor(x + (dx + rx) / 2), Math.floor(y + (dy + ry) / 2)); + const left = inside(Math.floor(x + (dx - rx) / 2), Math.floor(y + (dy - ry) / 2)); + if (right && left) [dx, dy] = [dy, -dx]; + else if (!right) [dx, dy] = [-dy, dx]; + if (!right || left) corners.push({ x, y }); + if (x === sx && y === sy && dx === 1 && dy === 0) break; + } + return corners; +} + +function triangleArea(a: Vec2, b: Vec2, c: Vec2): number { + return Math.abs((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)) / 2; +} + +/** + * Takes away the corner that changes the outline least (Visvalingam–Whyatt) until at most + * `maxPoints` are left and none is a mere step of the pixel grid. A corner whose removal would + * make the outline cross itself stays. + */ +function simplify(ring: readonly Vec2[], maxPoints: number): Vec2[] { + const points = [...ring]; + const areaAt = (i: number) => + triangleArea( + points[(i + points.length - 1) % points.length]!, + points[i]!, + points[(i + 1) % points.length]!, + ); + const areas = points.map((_, i) => areaAt(i)); + while (points.length > MIN_TRACE_POINTS) { + const refused = new Set(); + let removed = false; + for (;;) { + let best = -1; + for (let i = 0; i < points.length; i++) + if (!refused.has(i) && (best < 0 || areas[i]! < areas[best]!)) best = i; + if (best < 0 || (points.length <= maxPoints && areas[best]! >= STEP_AREA)) break; + if (!removable(points, best)) { + refused.add(best); + continue; + } + points.splice(best, 1); + areas.splice(best, 1); + const before = (best + points.length - 1) % points.length; + areas[before] = areaAt(before); + areas[best % points.length] = areaAt(best % points.length); + removed = true; + break; + } + if (!removed) break; + } + return points; +} + +/** Whether the edge that replaces corner `i` stays clear of every edge it does not share a corner with. */ +function removable(points: readonly Vec2[], i: number): boolean { + const n = points.length; + const a = (i + n - 1) % n; + const c = (i + 1) % n; + const from = points[a]!; + const to = points[c]!; + for (let j = 0; j < n; j++) { + if (j === (a + n - 1) % n || j === a || j === i || j === c) continue; + if (segmentsMeet(from, to, points[j]!, points[(j + 1) % n]!)) return false; + } + return true; +} + +/** The hull of the points – the outline of last resort for a pinched island. */ +function convexHull(points: readonly Vec2[]): Vec2[] { + const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y); + const turn = (o: Vec2, a: Vec2, b: Vec2) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x); + const half = (list: Vec2[]) => { + const out: Vec2[] = []; + for (const p of list) { + while (out.length >= 2 && turn(out[out.length - 2]!, out[out.length - 1]!, p) <= 0) out.pop(); + out.push(p); + } + out.pop(); + return out; + }; + return [...half(sorted), ...half([...sorted].reverse())]; +} diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 692217a6..d238e2cf 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -459,6 +459,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E rect: () => textureEditor.canvasRect(), /** Whether a press edits the selected frame's collision outline (#141). */ collisionMode: () => textureEditor.canvas.collisionMode, + /** The open "Generate collision…" dialog (#161), or null. */ + generateDialog: () => textureEditor.generateDialog.current()?.info ?? null, }, /** The Vector art tab (#129, stage 3): where a canvas pixel is, and which tool a press uses. */ vectorArt: { diff --git a/src/editor/texture/GenerateCollisionDialog.ts b/src/editor/texture/GenerateCollisionDialog.ts new file mode 100644 index 00000000..1e20167d --- /dev/null +++ b/src/editor/texture/GenerateCollisionDialog.ts @@ -0,0 +1,125 @@ +import type { TextureAsset, TextureFrame } from '../../core/model/Asset'; +import type { Vec2 } from '../../core/model/Properties'; +import { + DEFAULT_TRACE, + MAX_TRACE_POINTS, + MIN_TRACE_POINTS, + type TraceOptions, +} from '../../core/model/traceCollision'; +import { type Button, Checkbox, Label, NumberField, type Widget } from '../../ui'; +import { FormDialog } from '../FormDialog'; + +const FIELD_WIDTH = 76; + +/** A frame's outlines as the picture gives them, or null while the picture is not in memory. */ +export type TraceFrame = (frame: TextureFrame, options: TraceOptions) => Vec2[][] | null; + +/** + * Reads the collision outlines of a frame – or of every frame – off the picture (#161). The + * summary traces as the numbers change, so what OK writes is known before it is pressed. + */ +export class GenerateCollisionDialog extends FormDialog { + private readonly options: TraceOptions = { ...DEFAULT_TRACE }; + private all: boolean; + private readonly summary: Label; + private readonly confirm: Button; + private readonly controls: Record<'points' | 'threshold' | 'all', Widget>; + private result = new Map(); + + constructor( + private readonly asset: TextureAsset, + private readonly selected: TextureFrame | null, + private readonly trace: TraceFrame, + onGenerate: (outlines: Map) => void, + ) { + super(340, `Generate collision for "${asset.name}"`); + this.all = !selected; + + const points = new NumberField(this.options.maxPoints, { + min: MIN_TRACE_POINTS, + max: MAX_TRACE_POINTS, + step: 1, + decimals: 0, + onInput: (value) => this.setOption('maxPoints', value), + onChange: (value) => this.setOption('maxPoints', value), + }); + points.minWidth = FIELD_WIDTH; + this.content.add(this.formRow(this.fieldLabel('Max points'), points)); + + const threshold = new NumberField(this.options.threshold * 100, { + min: 1, + max: 100, + step: 1, + decimals: 0, + onInput: (value) => this.setOption('threshold', value / 100), + onChange: (value) => this.setOption('threshold', value / 100), + }); + threshold.minWidth = FIELD_WIDTH; + this.content.add( + this.formRow(this.fieldLabel('Solid from'), threshold, new Label('% alpha', { style: 'muted' })), + ); + + const box = new Checkbox(this.all); + box.setEnabled(selected !== null); + box.onChange = (value) => { + this.all = Boolean(value); + this.refreshSummary(); + }; + this.content.add(this.formRow(box, new Label('All frames of the texture', { style: 'muted' }))); + this.content.add(new Label('Replaces the outlines these frames have.', { style: 'muted' })); + + this.summary = new Label('', { style: 'muted' }); + this.content.add(this.summary); + + this.confirm = this.addButtons('Generate', 'primary', () => onGenerate(this.result)); + this.controls = { points, threshold, all: box }; + this.refreshSummary(); + } + + /** What the summary says, whether OK would write anything, and where the controls sit – for the check scripts. */ + get info(): { + summary: string; + enabled: boolean; + all: boolean; + at: Record<'points' | 'threshold' | 'all', { x: number; y: number }>; + } { + const centre = (widget: Widget) => { + const { x, y } = widget.toGlobal({ x: widget.w / 2, y: widget.h / 2 }); + return { x, y }; + }; + const { points, threshold, all } = this.controls; + const at = { points: centre(points), threshold: centre(threshold), all: centre(all) }; + return { summary: this.summary.text, enabled: this.confirm.enabled, all: this.all, at }; + } + + private setOption(key: K, value: TraceOptions[K]): void { + this.options[key] = value; + this.refreshSummary(); + } + + private refreshSummary(): void { + const frames = this.all || !this.selected ? this.asset.frames : [this.selected]; + this.result = new Map(); + let waiting = false; + for (const frame of frames) { + const outlines = this.trace(frame, this.options); + if (outlines) this.result.set(frame.id, outlines); + else waiting = true; + } + const outlines = [...this.result.values()].flat(); + const corners = outlines.reduce((sum, outline) => sum + outline.length, 0); + const scope = frames.length === 1 ? `Frame "${frames[0]!.name}"` : `${frames.length} frames`; + const text = waiting + ? 'The picture is still loading.' + : outlines.length === 0 + ? 'No pixel is that opaque – no outline.' + : `${scope}: ${plural(outlines.length, 'outline')}, ${plural(corners, 'corner')}`; + this.summary.setText(text); + this.confirm.setEnabled(!waiting && outlines.length > 0); + this.refreshLayout(); + } +} + +function plural(count: number, word: string): string { + return `${count} ${word}${count === 1 ? '' : 's'}`; +} diff --git a/src/editor/texture/TextureEditor.ts b/src/editor/texture/TextureEditor.ts index 7a259907..aed47cdd 100644 --- a/src/editor/texture/TextureEditor.ts +++ b/src/editor/texture/TextureEditor.ts @@ -1,8 +1,12 @@ -import { SliceFramesCommand } from '../../core/commands/frameCommands'; +import { SetFramesCollisionCommand, SliceFramesCommand } from '../../core/commands/frameCommands'; import type { Asset, TextureAsset } from '../../core/model/Asset'; +import { alphaOfRect, traceOutlines } from '../../core/model/traceCollision'; import { Button } from '../../ui'; +import { textureLibrary } from '../../viewport/TextureLibrary'; import { AssetTabEditor } from '../AssetTabEditor'; +import { dialogSlot } from '../FormDialog'; import type { EditorState } from '../state'; +import { GenerateCollisionDialog, type TraceFrame } from './GenerateCollisionDialog'; import { SliceDialog } from './SliceDialog'; import { TextureCanvas } from './TextureCanvas'; @@ -13,6 +17,8 @@ export class TextureEditor extends AssetTabEditor { private readonly sliceButton: Button; /** Switches the canvas between editing frames and editing their collision outlines (#141). */ private readonly collisionButton: Button; + private readonly generateButton: Button; + readonly generateDialog = dialogSlot(); constructor(state: EditorState) { super(state, { infoWidth: INFO_WIDTH }); @@ -21,7 +27,8 @@ export class TextureEditor extends AssetTabEditor { toggle: true, onClick: () => this.setCollisionMode(!this.canvas.collisionMode), }); - this.mount(new TextureCanvas(state), [this.sliceButton, this.collisionButton]); + this.generateButton = new Button('Generate collision…', { onClick: () => this.openGenerate() }); + this.mount(new TextureCanvas(state), [this.sliceButton, this.collisionButton, this.generateButton]); } setCollisionMode(on: boolean): void { @@ -56,6 +63,24 @@ export class TextureEditor extends AssetTabEditor { protected override syncExtras(asset: TextureAsset | null): void { // Slicing needs a known picture size; without one it could only fail with a notice. this.sliceButton.setEnabled(Boolean(asset && asset.width > 0 && asset.height > 0)); + // Outlines belong to frames; a texture without one has nowhere to put them. + this.generateButton.setEnabled(Boolean(asset && asset.frames.length > 0)); + } + + private openGenerate(): void { + const asset = this.asset(); + if (!asset || asset.frames.length === 0) return; + const s = this.state.selection; + const selected = (s?.kind === 'frame' && asset.frames.find((f) => f.id === s.frameId)) || null; + const pixels = textureLibrary.pixels(asset.file); + const trace: TraceFrame = (frame, options) => + pixels && + traceOutlines(alphaOfRect(pixels.data, pixels.width, pixels.height, frame), frame.w, frame.h, options); + this.generateDialog.open( + new GenerateCollisionDialog(asset, selected, trace, (outlines) => + this.state.execute(new SetFramesCollisionCommand(asset.id, outlines)), + ), + ); } private openSlice(): void { diff --git a/src/viewport/TextureLibrary.ts b/src/viewport/TextureLibrary.ts index 031b71de..8a7f486f 100644 --- a/src/viewport/TextureLibrary.ts +++ b/src/viewport/TextureLibrary.ts @@ -121,6 +121,21 @@ export class TextureLibrary extends FileLibrary { return texture; } + /** + * The colours of an uploaded picture, read back from the image it was loaded from; null while it + * loads, and for a generated picture, which lives on the GPU only. + */ + pixels(file: string): ImageData | null { + const sheet = isGeneratedFile(file) ? null : this.load(file); + const picture = sheet?.source.resource as CanvasImageSource | null | undefined; + if (!sheet || !picture) return null; + const { pixelWidth: w, pixelHeight: h } = sheet.source; + const context = new OffscreenCanvas(w, h).getContext('2d', { willReadFrequently: true }); + if (!context) return null; + context.drawImage(picture, 0, 0); + return context.getImageData(0, 0, w, h); + } + protected fetchFile(url: string): Promise { return Assets.load(url); } diff --git a/tests/core/trace-collision.test.ts b/tests/core/trace-collision.test.ts new file mode 100644 index 00000000..5a1032a6 --- /dev/null +++ b/tests/core/trace-collision.test.ts @@ -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); + }); +}); diff --git a/tools/e2e/42-shapes.mjs b/tools/e2e/42-shapes.mjs index 687b3c7c..9a58bd5a 100644 --- a/tools/e2e/42-shapes.mjs +++ b/tools/e2e/42-shapes.mjs @@ -6,7 +6,8 @@ import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs'; * (#141). What only a running editor can show: the corners of a polygon collider are edited on * its outline in the scene, the engine lets a capsule slide down a polygon ramp, the Texture tab * draws a frame's outline with clicks, and a painted map blocks with that outline in the game – as - * does a collider that wears the frame's outline (#193). + * does a collider that wears the frame's outline (#193), and "Generate collision…" reads that + * outline off the picture (#161). */ /** A 32 × 32 picture, pasted like a file: the texture the outline is drawn on. */ @@ -22,6 +23,26 @@ const PASTE_PICTURE = `(async () => { window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true })); })()`; +/** + * A 64 × 32 sheet, pasted like a file, painted pixel by pixel so no edge is smoothed: on the left a + * wedge (pixels with x + y ≥ 32) and a band of alpha 0.3 above it, on the right a 16 × 16 square. + */ +const PASTE_SHEET = `(async () => { + const canvas = document.createElement('canvas'); + canvas.width = 64; + canvas.height = 32; + const g = canvas.getContext('2d'); + g.fillStyle = '#c04040'; + for (let y = 0; y < 32; y++) for (let x = 0; x < 32; x++) if (x + y >= 32) g.fillRect(x, y, 1, 1); + g.fillRect(40, 8, 16, 16); + g.fillStyle = 'rgba(64, 64, 192, 0.3)'; + g.fillRect(0, 0, 20, 4); + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); + const transfer = new DataTransfer(); + transfer.items.add(new File([blob], 'generate.png', { type: 'image/png' })); + window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true })); +})()`; + /** Walks its owner right and a little down, colliding with solids: 2 and 0.6 a frame at 60 fps. */ const climbGraph = { nodes: [ @@ -421,4 +442,94 @@ export async function shapes(t) { JSON.stringify({ frames: frameClimb.length, peak, top: F - 42, last: frameClimb.at(-1) }), ); await clearShapes(); + + // 42f. "Generate collision…" reads the outlines off the picture (#161): a wedge and a faint band + // (alpha 30 %) in one frame, a square in the other. From 50 % the band is not solid, from + // 20 % it is an outline of its own; "All frames" writes both frames in one undo step. + await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); + await evaluate(PASTE_SHEET); + await sleep(2500); + const sheet = await json( + "(() => { const a = __gm.state.project.assets.filter((a) => a.type === 'texture').at(-1);" + + ' return a ? { id: a.id, w: a.width, h: a.height } : null; })()', + ); + await mutate(` + const sheet = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(sheet?.id)}); + sheet.frames.push({ id: 'gc-left', name: 'Left', x: 0, y: 0, w: 32, h: 32 }, + { id: 'gc-right', name: 'Right', x: 32, y: 0, w: 32, h: 32 });`); + await select({ kind: 'frame', assetId: sheet?.id, frameId: 'gc-left' }); + await showTab('texture'); + await settle(); + await clickText('Generate collision…'); + await settle(); + const dialog = () => json('__gm.texture.generateDialog()'); + const opened = await dialog(); + check( + 'Generate collision… opens on the selected frame and names what it would write from 50 % alpha', + sheet?.w === 64 && + opened?.enabled === true && + opened.all === false && + /^Frame "Left": 1 outline, [34] corners$/.test(opened.summary), + JSON.stringify({ sheet, opened }), + ); + const retype = async (at, text) => { + await click(at.x, at.y); + await press('a', { ctrl: true }); + await t.typeText(text); + await press('Enter'); + await settle(); + }; + await retype(opened.at.threshold, '20'); + const faint = await dialog(); + await shot('42-generate-dialog'); + await retype(opened.at.threshold, '50'); + check( + 'from 20 % alpha the faint band is solid too and becomes an outline of its own', + /^Frame "Left": 2 outlines, \d+ corners$/.test(faint?.summary ?? '') && + /^Frame "Left": 1 outline/.test((await dialog())?.summary ?? ''), + JSON.stringify(faint), + ); + await click(opened.at.all.x, opened.at.all.y); + await settle(); + const both = await dialog(); + await clickText('Generate'); + await settle(); + const outlines = () => + json( + `Object.fromEntries(__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(sheet?.id)})` + + `.frames.map((f) => [f.id, f.collision ?? null]))`, + ); + const written = await outlines(); + const cornerSet = (ring) => (ring ?? []).map((p) => `${p.x},${p.y}`).sort(); + const ringArea = (ring) => + Math.abs( + ring.reduce( + (sum, p, i) => sum + p.x * ring[(i + 1) % ring.length].y - ring[(i + 1) % ring.length].x * p.y, + 0, + ), + ) / 2; + check( + 'with All frames, Generate outlines the wedge and the square of both frames', + both?.all === true && + /^2 frames: 2 outlines/.test(both.summary) && + written['gc-left']?.length === 1 && + written['gc-left'][0].length <= 4 && + Math.abs(ringArea(written['gc-left'][0]) - 512) < 26 && + JSON.stringify(cornerSet(written['gc-right']?.[0])) === + JSON.stringify(['24,24', '24,8', '8,24', '8,8']), + JSON.stringify({ both, written }), + ); + await clickText('Collision'); + await settle(); + await shot('42-generated-outline'); + await clickText('Collision'); + await undo(); + const undone = await outlines(); + check( + 'one undo takes the outlines of both frames back', + undone['gc-left'] === null && undone['gc-right'] === null, + JSON.stringify(undone), + ); + await showTab('scene'); + await settle(); }