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>
372 lines
15 KiB
TypeScript
372 lines
15 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|