Add MaxRects Best-Area-Fit packer

Deterministic placement heuristic (Best-Area-Fit with tiebreak on
shorter remaining short side). Power-of-2-rounded output bounds for
GPU friendliness. Oversize hard-fails with packed-area diagnostic.

Test bounds corrected: guillotine split fills tiles left-to-right,
so 3x 32x32 tiles produce boundsW=128 (not 64 as the plan draft had).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Axel Meyer
2026-05-21 22:44:00 +02:00
parent 83499850fb
commit 52689e9a9c
2 changed files with 136 additions and 0 deletions

88
src/pack.js Normal file
View File

@@ -0,0 +1,88 @@
// src/pack.js
// MaxRects Best-Area-Fit bin packing for atlas UV layout.
// Deterministic: input order matters, no random tiebreak.
//
// Input: rects = [{ w, h, ...rest }] (already sorted by name for determinism)
// Output: { placed: [{ x, y, w, h, ...rest }], boundsW, boundsH }
// or { error: 'oversize', ... } if max-size exceeded.
function packMaxRects(rects, maxSize) {
// Free rectangles list — initially one big rectangle of max size
let freeRects = [{ x: 0, y: 0, w: maxSize, h: maxSize }];
const placed = [];
let maxX = 0;
let maxY = 0;
for (const rect of rects) {
// Find Best-Area-Fit slot — smallest free rect that still contains rect
let bestIdx = -1;
let bestArea = Infinity;
let bestShortSide = Infinity;
for (let i = 0; i < freeRects.length; i++) {
const f = freeRects[i];
if (f.w >= rect.w && f.h >= rect.h) {
const area = f.w * f.h;
const shortSide = Math.min(f.w - rect.w, f.h - rect.h);
// Best area, tiebreak by shorter remaining short side
if (area < bestArea || (area === bestArea && shortSide < bestShortSide)) {
bestIdx = i;
bestArea = area;
bestShortSide = shortSide;
}
}
}
if (bestIdx === -1) {
return {
error: 'oversize',
message: `pack: tile '${rect.name || 'unnamed'}' (${rect.w}x${rect.h}) `
+ `does not fit in remaining ${maxSize}x${maxSize} canvas`,
placed,
};
}
const slot = freeRects[bestIdx];
const px = slot.x;
const py = slot.y;
placed.push({ ...rect, x: px, y: py });
maxX = Math.max(maxX, px + rect.w);
maxY = Math.max(maxY, py + rect.h);
// Split slot into up to two free rects (guillotine-style)
const newFree = [];
for (let i = 0; i < freeRects.length; i++) {
if (i === bestIdx) continue;
newFree.push(freeRects[i]);
}
// Right of placed
if (slot.w > rect.w) {
newFree.push({
x: px + rect.w,
y: py,
w: slot.w - rect.w,
h: rect.h,
});
}
// Below placed
if (slot.h > rect.h) {
newFree.push({
x: px,
y: py + rect.h,
w: slot.w,
h: slot.h - rect.h,
});
}
freeRects = newFree;
}
// Round bounds up to next power of 2 >= 64 for GPU friendliness
const boundsW = nextPowOf2(Math.max(maxX, 64));
const boundsH = nextPowOf2(Math.max(maxY, 64));
return { placed, boundsW, boundsH };
}
function nextPowOf2(n) {
let p = 64;
while (p < n) p *= 2;
return p;
}
module.exports = { packMaxRects };