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

+1 -1
View File
@@ -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);
+204
View File
@@ -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<number>,
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<number>,
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<number>();
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())];
}