/** * 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; } 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())]; }