Files
feuerwerk/src/core/model/polygon.ts
T
adminandClaude Opus 5 2d79230b55
game-maker / gate (push) Successful in 2m13s
game-maker / suite (push) Successful in 7m42s
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>
2026-09-21 08:00:30 +02:00

372 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Plain polygons as outlines of points: checking one, and cutting a concave one into convex pieces
* – what collision works with (#151), since the overlap of two convex shapes is a matter of a few
* axes and the overlap of two concave ones is not.
*/
import { isList, isStruct, type PropertyValue, type Vec2 } from './Properties';
/** Points closer than this are one point, and a turn smaller than this is a straight line. */
const EPSILON = 1e-9;
/** A coordinate as stored: a number, or a text spelling one. `Number(null)` would be 0, not missing. */
function coordinate(value: unknown): number {
if (typeof value === 'number') return value;
return typeof value === 'string' && value.trim() !== '' ? Number(value) : Number.NaN;
}
/** The points a stored list holds: `{x, y}` items with finite numbers; anything else is left out. */
export function pointsOf(value: PropertyValue | undefined): Vec2[] {
if (!isList(value)) return [];
const points: Vec2[] = [];
for (const item of value) {
if (!isStruct(item)) continue;
const x = coordinate(item.x);
const y = coordinate(item.y);
if (Number.isFinite(x) && Number.isFinite(y)) points.push({ x, y });
}
return points;
}
/** Positive when the points run clockwise on screen (y grows downwards), negative the other way. */
export function signedArea(points: readonly Vec2[]): number {
let sum = 0;
for (let i = 0; i < points.length; i++) {
const a = points[i]!;
const b = points[(i + 1) % points.length]!;
sum += a.x * b.y - b.x * a.y;
}
return sum / 2;
}
function cross(o: Vec2, a: Vec2, b: Vec2): number {
return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
}
/** The outline without repeated points and without points in the middle of a straight edge. */
export function cleanOutline(points: readonly Vec2[]): Vec2[] {
let ring = points.filter((p, i) => {
const next = points[(i + 1) % points.length]!;
return points.length < 2 || Math.hypot(p.x - next.x, p.y - next.y) > EPSILON;
});
let changed = true;
while (changed && ring.length >= 3) {
changed = false;
for (let i = 0; i < ring.length; i++) {
const prev = ring[(i + ring.length - 1) % ring.length]!;
const next = ring[(i + 1) % ring.length]!;
const p = ring[i]!;
const span = Math.hypot(next.x - prev.x, next.y - prev.y) * Math.hypot(p.x - prev.x, p.y - prev.y);
if (Math.abs(cross(prev, p, next)) <= EPSILON * Math.max(1, span)) {
ring = ring.filter((_, j) => j !== i);
changed = true;
break;
}
}
}
return ring;
}
/** Whether the open segments ab and cd cross or touch. */
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);
const d4 = cross(a, b, d);
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) return true;
const on = (p: Vec2, q: Vec2, r: Vec2, side: number) =>
Math.abs(side) <= EPSILON &&
Math.min(p.x, q.x) - EPSILON <= r.x &&
r.x <= Math.max(p.x, q.x) + EPSILON &&
Math.min(p.y, q.y) - EPSILON <= r.y &&
r.y <= Math.max(p.y, q.y) + EPSILON;
return on(c, d, a, d1) || on(c, d, b, d2) || on(a, b, c, d3) || on(a, b, d, d4);
}
/**
* Why the outline is no polygon one can collide with, or '' when it is one: at least three corners,
* some area, and no edge crossing or touching another that is not its neighbour.
*/
export function polygonProblem(points: readonly Vec2[]): string {
const ring = cleanOutline(points);
if (ring.length < 3) return 'A polygon needs at least three corners.';
if (Math.abs(signedArea(ring)) <= EPSILON) return 'The polygon has no area.';
const n = ring.length;
for (let i = 0; i < n; i++)
for (let j = i + 1; j < n; j++) {
if (j === i + 1 || (i === 0 && j === n - 1)) continue;
if (segmentsMeet(ring[i]!, ring[(i + 1) % n]!, ring[j]!, ring[(j + 1) % n]!))
return 'The outline crosses itself.';
}
return '';
}
/** Whether every corner turns the same way. */
export function isConvex(ring: readonly Vec2[]): boolean {
const sign = Math.sign(signedArea(ring));
for (let i = 0; i < ring.length; i++) {
const turn = cross(ring[i]!, ring[(i + 1) % ring.length]!, ring[(i + 2) % ring.length]!);
if (turn * sign < -EPSILON) return false;
}
return true;
}
/** Whether `p` lies inside the triangle abc of the given winding, its edges counting as inside. */
function inTriangle(p: Vec2, a: Vec2, b: Vec2, c: Vec2, sign: number): boolean {
return (
cross(a, b, p) * sign >= -EPSILON &&
cross(b, c, p) * sign >= -EPSILON &&
cross(c, a, p) * sign >= -EPSILON
);
}
/** Cuts a simple outline into triangles by clipping ears, as indices into `ring`. */
function triangulate(ring: readonly Vec2[]): [number, number, number][] {
const sign = Math.sign(signedArea(ring));
const left = ring.map((_, i) => i);
const triangles: [number, number, number][] = [];
let guard = ring.length * ring.length;
while (left.length > 3 && guard-- > 0) {
let clipped = false;
for (let k = 0; k < left.length; k++) {
const i0 = left[(k + left.length - 1) % left.length]!;
const i1 = left[k]!;
const i2 = left[(k + 1) % left.length]!;
const [a, b, c] = [ring[i0]!, ring[i1]!, ring[i2]!];
if (cross(a, b, c) * sign <= EPSILON) continue;
const blocked = left.some(
(j) => j !== i0 && j !== i1 && j !== i2 && inTriangle(ring[j]!, a, b, c, sign),
);
if (blocked) continue;
triangles.push([i0, i1, i2]);
left.splice(k, 1);
clipped = true;
break;
}
if (!clipped) break;
}
if (left.length === 3) triangles.push([left[0]!, left[1]!, left[2]!]);
return triangles;
}
/**
* The outline as convex pieces that together cover it exactly: itself when it is convex, else its
* triangles merged back along every shared edge whose removal leaves the piece convex
* (Hertel-Mehlhorn) – at most four times as many pieces as the fewest possible, and few in practice.
* An outline with a problem (see `polygonProblem`) has no pieces.
*/
export function convexPieces(points: readonly Vec2[]): Vec2[][] {
if (polygonProblem(points)) return [];
const ring = cleanOutline(points);
if (isConvex(ring)) return [ring];
let pieces = triangulate(ring).map((t) => [...t]);
let merged = true;
while (merged) {
merged = false;
search: for (let p = 0; p < pieces.length; p++)
for (let q = p + 1; q < pieces.length; q++) {
const joined = joinAlongSharedEdge(pieces[p]!, pieces[q]!);
if (!joined || !isConvex(cleanOutline(joined.map((i) => ring[i]!)))) continue;
pieces[p] = joined;
pieces = pieces.filter((_, k) => k !== q);
merged = true;
break search;
}
}
return pieces.map((piece) => cleanOutline(piece.map((i) => ring[i]!)));
}
/**
* The part of the outline inside the rectangle x0 … x1, y0 … y1 (Sutherland-Hodgman), cleaned; an
* empty list when nothing with area is left. Exact for a convex outline. A concave one that the cut
* splits in two comes back as one ring joined along the rectangle's edge – good enough to draw, but
* collision cuts the convex pieces instead.
*/
export function clipToRect(points: readonly Vec2[], x0: number, y0: number, x1: number, y1: number): Vec2[] {
const sides: [(p: Vec2) => number, (a: Vec2, b: Vec2) => Vec2][] = [
[(p) => p.x - x0, (a, b) => ({ x: x0, y: at(a.y, b.y, (x0 - a.x) / (b.x - a.x)) })],
[(p) => x1 - p.x, (a, b) => ({ x: x1, y: at(a.y, b.y, (x1 - a.x) / (b.x - a.x)) })],
[(p) => p.y - y0, (a, b) => ({ x: at(a.x, b.x, (y0 - a.y) / (b.y - a.y)), y: y0 })],
[(p) => y1 - p.y, (a, b) => ({ x: at(a.x, b.x, (y1 - a.y) / (b.y - a.y)), y: y1 })],
];
let ring: Vec2[] = [...points];
for (const [inside, cut] of sides) {
const next: Vec2[] = [];
for (let i = 0; i < ring.length; i++) {
const a = ring[i]!;
const b = ring[(i + 1) % ring.length]!;
const aIn = inside(a) >= 0;
const bIn = inside(b) >= 0;
if (aIn) next.push(a);
if (aIn !== bIn) next.push(cut(a, b));
}
ring = next;
if (ring.length === 0) return [];
}
const cleaned = cleanOutline(ring);
return cleaned.length >= 3 && Math.abs(signedArea(cleaned)) > EPSILON ? cleaned : [];
}
/**
* A share `t` of the way from a to b, on the axis the cut line crosses; the other axis is set to the
* line itself, so two neighbouring cuts along the same line meet exactly.
*/
function at(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
/** A line of an outline to draw: its points, and whether it comes back round to the first. */
export interface BoundaryLine {
points: Vec2[];
closed: boolean;
}
/** Lines closer than this in angle and offset are one line, and points closer than this one point. */
const SAME = 1e-7;
/** An edge on its line: `u` the line's direction, `c` its offset, `sign` +1 along `u` and -1 against it. */
interface LineEdge {
angle: number;
c: number;
u: Vec2;
a: Vec2;
b: Vec2;
sign: number;
}
/**
* The outer edges of rings lying side by side (#173), for drawing. On every line the edges along it
* are counted with their direction, all rings turned the same way first: where two rings meet, the
* shared stretch runs once each way and adds up to nothing, so it is left out – and so is a stretch
* a ring runs down and back up, which is what `clipToRect` leaves where it joins a concave cut. A
* stretch two rings share the same way is drawn once. Rings that overlap keep the edges lying inside
* each other. What is left is chained into lines, closed where they come back round.
*/
export function boundaryOf(rings: readonly (readonly Vec2[])[]): BoundaryLine[] {
const edges: LineEdge[] = [];
for (const ring of rings) {
const area = signedArea(ring);
if (Math.abs(area) <= EPSILON) continue;
const turned = area > 0 ? ring : [...ring].reverse();
for (let i = 0; i < turned.length; i++) {
const a = turned[i]!;
const b = turned[(i + 1) % turned.length]!;
const length = Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2);
if (length <= SAME) continue;
let u = { x: (b.x - a.x) / length, y: (b.y - a.y) / length };
// One direction per line, so both ways along it land together.
const sign = u.x < -EPSILON || (Math.abs(u.x) <= EPSILON && u.y < 0) ? -1 : 1;
if (sign < 0) u = { x: -u.x, y: -u.y };
edges.push({ angle: Math.atan2(u.y, u.x), c: u.x * a.y - u.y * a.x, u, a, b, sign });
}
}
// Grouped by angle first and then by offset, each within `SAME` of its neighbour: sorting by both
// at once would let a hair's difference in angle split a line.
const segments: [Vec2, Vec2][] = [];
edges.sort((p, q) => p.angle - q.angle);
for (let start = 0, i = 1; i <= edges.length; i++) {
if (i < edges.length && edges[i]!.angle - edges[i - 1]!.angle <= SAME) continue;
const bundle = edges.slice(start, i).sort((p, q) => p.c - q.c);
for (let from = 0, j = 1; j <= bundle.length; j++) {
if (j < bundle.length && bundle[j]!.c - bundle[j - 1]!.c <= SAME) continue;
uncovered(bundle.slice(from, j), segments);
from = j;
}
start = i;
}
return chain(segments);
}
/** Adds the stretches of one line where the edges along it do not add up to nothing, each the way the count runs. */
function uncovered(line: LineEdge[], out: [Vec2, Vec2][]): void {
const u = line[0]!.u;
const events: { s: number; p: Vec2; delta: number }[] = [];
for (const edge of line) {
const from = edge.sign > 0 ? edge.a : edge.b;
const to = edge.sign > 0 ? edge.b : edge.a;
events.push(
{ s: from.x * u.x + from.y * u.y, p: from, delta: edge.sign },
{ s: to.x * u.x + to.y * u.y, p: to, delta: -edge.sign },
);
}
events.sort((p, q) => p.s - q.s);
// Events within `SAME` of each other are one stop, at the point of the first.
let count = 0;
let stop = events[0]!;
for (let i = 0; i < events.length; i++) {
const e = events[i]!;
if (e.s - events[Math.max(i - 1, 0)]!.s > SAME) {
if (count > 0) out.push([stop.p, e.p]);
else if (count < 0) out.push([e.p, stop.p]);
stop = e;
}
count += e.delta;
}
}
/** The segments joined end to start into lines; a line that meets its first point again is closed. */
function chain(segments: [Vec2, Vec2][]): BoundaryLine[] {
const cell = (v: number) => Math.round(v / (SAME * 10));
const byStart = new Map<string, number[]>();
const byEnd = new Map<string, number[]>();
const file = (map: Map<string, number[]>, p: Vec2, i: number) => {
const key = `${cell(p.x)},${cell(p.y)}`;
const list = map.get(key);
if (list) list.push(i);
else map.set(key, [i]);
};
segments.forEach(([a, b], i) => {
file(byStart, a, i);
file(byEnd, b, i);
});
const used = segments.map(() => false);
const near = (p: Vec2, q: Vec2) => (p.x - q.x) ** 2 + (p.y - q.y) ** 2 <= SAME * SAME;
const find = (map: Map<string, number[]>, p: Vec2, end: 0 | 1): number => {
for (let dx = -1; dx <= 1; dx++)
for (let dy = -1; dy <= 1; dy++)
for (const i of map.get(`${cell(p.x) + dx},${cell(p.y) + dy}`) ?? [])
if (!used[i] && near(segments[i]![end], p)) return i;
return -1;
};
const lines: BoundaryLine[] = [];
for (let first = 0; first < segments.length; first++) {
if (used[first]) continue;
used[first] = true;
const points = [...segments[first]!];
for (
let i = find(byStart, points[points.length - 1]!, 0);
i >= 0;
i = find(byStart, points[points.length - 1]!, 0)
) {
used[i] = true;
points.push(segments[i]![1]);
}
if (points.length > 2 && near(points[0]!, points[points.length - 1]!)) {
points.pop();
lines.push({ points: cleanOutline(points), closed: true });
continue;
}
for (let i = find(byEnd, points[0]!, 1); i >= 0; i = find(byEnd, points[0]!, 1)) {
used[i] = true;
points.unshift(segments[i]![0]);
}
lines.push({ points, closed: false });
}
return lines;
}
/** Two pieces (index rings of the same winding) joined across the edge they share, if they share one. */
function joinAlongSharedEdge(a: number[], b: number[]): number[] | null {
for (let i = 0; i < a.length; i++) {
const u = a[i]!;
const v = a[(i + 1) % a.length]!;
const j = b.findIndex((x, k) => x === v && b[(k + 1) % b.length] === u);
if (j < 0) continue;
// Walk a from v round to u, then b from u round to v, skipping the shared edge.
const out: number[] = [];
for (let k = 0; k < a.length; k++) out.push(a[(i + 1 + k) % a.length]!);
for (let k = 2; k < b.length; k++) out.push(b[(j + k) % b.length]!);
return out;
}
return null;
}