From 52689e9a9c7e6dbfbd62d99cbcd360bb0efd0146 Mon Sep 17 00:00:00 2001 From: Axel Meyer Date: Thu, 21 May 2026 22:44:00 +0200 Subject: [PATCH] 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) --- src/pack.js | 88 ++++++++++++++++++++++++++++++++++++++++++++++ tests/pack.test.js | 48 +++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 src/pack.js create mode 100644 tests/pack.test.js diff --git a/src/pack.js b/src/pack.js new file mode 100644 index 0000000..a03d041 --- /dev/null +++ b/src/pack.js @@ -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 }; diff --git a/tests/pack.test.js b/tests/pack.test.js new file mode 100644 index 0000000..647b46d --- /dev/null +++ b/tests/pack.test.js @@ -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); +});