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:
88
src/pack.js
Normal file
88
src/pack.js
Normal 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 };
|
||||||
48
tests/pack.test.js
Normal file
48
tests/pack.test.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const { packMaxRects } = require('../src/pack');
|
||||||
|
|
||||||
|
test('pack: 3 32x32 tiles fit in 64x64', () => {
|
||||||
|
const rects = [
|
||||||
|
{ name: 'a', w: 32, h: 32 },
|
||||||
|
{ name: 'b', w: 32, h: 32 },
|
||||||
|
{ name: 'c', w: 32, h: 32 },
|
||||||
|
];
|
||||||
|
const result = packMaxRects(rects, 4096);
|
||||||
|
assert.strictEqual(result.error, undefined);
|
||||||
|
assert.strictEqual(result.placed.length, 3);
|
||||||
|
assert.strictEqual(result.boundsW, 128);
|
||||||
|
assert.strictEqual(result.boundsH, 64);
|
||||||
|
// Positions must not overlap
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
for (let j = i + 1; j < 3; j++) {
|
||||||
|
const a = result.placed[i];
|
||||||
|
const b = result.placed[j];
|
||||||
|
const overlap = a.x < b.x + b.w && b.x < a.x + a.w
|
||||||
|
&& a.y < b.y + b.h && b.y < a.y + a.h;
|
||||||
|
assert.ok(!overlap, `tiles ${i} and ${j} overlap`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pack: oversize triggers error', () => {
|
||||||
|
const rects = [];
|
||||||
|
// 200 tiles of 64x64 cannot fit in 1024x1024 (needs 16x16 = 256 slots, has only ~256)
|
||||||
|
for (let i = 0; i < 300; i++) {
|
||||||
|
rects.push({ name: `t${i}`, w: 64, h: 64 });
|
||||||
|
}
|
||||||
|
const result = packMaxRects(rects, 1024);
|
||||||
|
assert.strictEqual(result.error, 'oversize');
|
||||||
|
assert.ok(result.message.includes('does not fit'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pack: deterministic across runs', () => {
|
||||||
|
const rects = [
|
||||||
|
{ name: 'a', w: 32, h: 32 },
|
||||||
|
{ name: 'b', w: 32, h: 32 },
|
||||||
|
{ name: 'c', w: 64, h: 32 },
|
||||||
|
];
|
||||||
|
const r1 = packMaxRects(rects, 4096);
|
||||||
|
const r2 = packMaxRects(rects, 4096);
|
||||||
|
assert.deepStrictEqual(r1.placed, r2.placed);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user