123 lines
4.1 KiB
TypeScript
123 lines
4.1 KiB
TypeScript
import { pack, unpackX, unpackY } from "../../helpers/helpers.js";
|
||
import type { Rect } from "../../types/types.js";
|
||
|
||
/**
|
||
* Greedy set-cover with a deterministic three-key score:
|
||
*
|
||
* 1. number of uncovered required pixels covered (primary)
|
||
* 2. covered / area ratio (efficiency — prefer compact rects)
|
||
* 3. proximity of rect centroid to the centroid of the remaining required
|
||
* pixels (tiebreaker — keep selections clustered)
|
||
*
|
||
* When no candidate rect intersects the remaining uncovered set, fall back
|
||
* to emitting 1×1 rects for every residual pixel. This guarantees the cover
|
||
* is always complete even on pathological inputs.
|
||
*/
|
||
export function enhancedGreedyCover(
|
||
candidates: ReadonlyArray<Rect>,
|
||
required: boolean[][],
|
||
width: number,
|
||
height: number
|
||
): Rect[] {
|
||
const uncovered = new Set<number>();
|
||
for (let y = 0; y < height; y++) {
|
||
for (let x = 0; x < width; x++) {
|
||
if (required[y]?.[x]) uncovered.add(pack(x, y, width));
|
||
}
|
||
}
|
||
if (uncovered.size === 0) return [];
|
||
|
||
const selected: Rect[] = [];
|
||
const maxDist = Math.sqrt(width ** 2 + height ** 2);
|
||
|
||
while (uncovered.size > 0) {
|
||
let cx = 0;
|
||
let cy = 0;
|
||
for (const idx of uncovered) {
|
||
cx += unpackX(idx, width);
|
||
cy += unpackY(idx, width);
|
||
}
|
||
cx /= uncovered.size;
|
||
cy /= uncovered.size;
|
||
|
||
let bestRect: Rect | null = null;
|
||
let bestScore = Number.NEGATIVE_INFINITY;
|
||
|
||
for (const rect of candidates) {
|
||
let covered = 0;
|
||
for (let ry = rect.y; ry < rect.y + rect.h; ry++) {
|
||
for (let rx = rect.x; rx < rect.x + rect.w; rx++) {
|
||
if (uncovered.has(pack(rx, ry, width))) covered++;
|
||
}
|
||
}
|
||
if (covered === 0) continue;
|
||
const area = rect.w * rect.h;
|
||
const efficiency = covered / area;
|
||
const rcx = rect.x + rect.w / 2;
|
||
const rcy = rect.y + rect.h / 2;
|
||
const dist = Math.sqrt((rcx - cx) ** 2 + (rcy - cy) ** 2);
|
||
const proximity = 1 - dist / maxDist;
|
||
const score = covered * 1000 + efficiency * 100 + proximity * 10;
|
||
if (score > bestScore) {
|
||
bestScore = score;
|
||
bestRect = rect;
|
||
}
|
||
}
|
||
|
||
if (!bestRect) {
|
||
for (const idx of uncovered) {
|
||
selected.push({
|
||
x: unpackX(idx, width),
|
||
y: unpackY(idx, width),
|
||
w: 1,
|
||
h: 1,
|
||
});
|
||
}
|
||
uncovered.clear();
|
||
break;
|
||
}
|
||
|
||
selected.push(bestRect);
|
||
for (let ry = bestRect.y; ry < bestRect.y + bestRect.h; ry++) {
|
||
for (let rx = bestRect.x; rx < bestRect.x + bestRect.w; rx++) {
|
||
uncovered.delete(pack(rx, ry, width));
|
||
}
|
||
}
|
||
}
|
||
return selected;
|
||
}
|
||
|
||
/**
|
||
* Per-island independent cover: build a local `required` mask for each
|
||
* island and run `enhancedGreedyCover` against the subset of candidates
|
||
* that overlap it. This avoids wasting selection budget on spanning rects
|
||
* between disconnected islands.
|
||
*/
|
||
export function perIslandCover(
|
||
islands: ReadonlyArray<{ readonly pixels: Set<number> }>,
|
||
candidates: ReadonlyArray<Rect>,
|
||
width: number,
|
||
height: number
|
||
): Rect[] {
|
||
const out: Rect[] = [];
|
||
for (const island of islands) {
|
||
const local: boolean[][] = Array.from({ length: height }, () =>
|
||
Array(width).fill(false)
|
||
);
|
||
for (const idx of island.pixels) {
|
||
(local[unpackY(idx, width)] as boolean[])[unpackX(idx, width)] =
|
||
true;
|
||
}
|
||
const localCandidates = candidates.filter((r) => {
|
||
for (let ry = r.y; ry < r.y + r.h; ry++) {
|
||
for (let rx = r.x; rx < r.x + r.w; rx++) {
|
||
if (local[ry]?.[rx]) return true;
|
||
}
|
||
}
|
||
return false;
|
||
});
|
||
out.push(...enhancedGreedyCover(localCandidates, local, width, height));
|
||
}
|
||
return out;
|
||
}
|