From 348b226c2698591878fd738c6b6d4d8526146c53 Mon Sep 17 00:00:00 2001 From: Axel Meyer Date: Tue, 16 Jun 2026 21:09:06 +0200 Subject: [PATCH] feat(pack-shelf): height-sort desc shelf packing with oversize loud-error --- src/pack-shelf.js | 54 ++++++++++++++++++++++++++++++ tests/pack-shelf.test.js | 71 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 src/pack-shelf.js create mode 100644 tests/pack-shelf.test.js diff --git a/src/pack-shelf.js b/src/pack-shelf.js new file mode 100644 index 0000000..a8be8d1 --- /dev/null +++ b/src/pack-shelf.js @@ -0,0 +1,54 @@ +// src/pack-shelf.js +// Shelf-Pack (height-sort desc, left-to-right per row). +// Deterministic: same input order + same maxSize -> same output. +// +// Input: rects = [{ alias, w, h, ...rest }] +// Output: { placed: [{ x, y, w, h, alias, ...rest }], boundsW, boundsH } +// or { error: 'oversize', message: '...' } + +function packShelf(rects, maxSize) { + // Sort by height descending; tie-break by alias to keep determinism + const sorted = [...rects].sort((a, b) => { + if (b.h !== a.h) return b.h - a.h; + return a.alias.localeCompare(b.alias); + }); + + const placed = []; + let cursorX = 0; + let cursorY = 0; + let rowHeight = 0; + let maxX = 0; + + for (const r of sorted) { + if (r.w > maxSize || r.h > maxSize) { + return { + error: 'oversize', + message: `pack-shelf: sprite "${r.alias}" (${r.w}x${r.h}) exceeds max-size ${maxSize}`, + }; + } + // Row-wrap if this sprite would exceed maxSize in width + if (cursorX + r.w > maxSize) { + cursorY += rowHeight; + cursorX = 0; + rowHeight = 0; + } + // Check total atlas height + if (cursorY + r.h > maxSize) { + return { + error: 'oversize', + message: `pack-shelf: cumulative atlas height exceeds max-size ${maxSize}`, + }; + } + placed.push({ ...r, x: cursorX, y: cursorY }); + cursorX += r.w; + if (cursorX > maxX) maxX = cursorX; + if (r.h > rowHeight) rowHeight = r.h; + } + return { + placed, + boundsW: maxX, + boundsH: cursorY + rowHeight, + }; +} + +module.exports = { packShelf }; diff --git a/tests/pack-shelf.test.js b/tests/pack-shelf.test.js new file mode 100644 index 0000000..3cbbd6c --- /dev/null +++ b/tests/pack-shelf.test.js @@ -0,0 +1,71 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const { packShelf } = require('../src/pack-shelf'); + +test('pack-shelf: 3 equal rects fit horizontally', () => { + const rects = [ + { alias: 'a', w: 32, h: 32 }, + { alias: 'b', w: 32, h: 32 }, + { alias: 'c', w: 32, h: 32 }, + ]; + const r = packShelf(rects, 4096); + assert.strictEqual(r.error, undefined); + assert.strictEqual(r.placed.length, 3); + // All in one row at y=0; row height = 32 + assert.strictEqual(r.boundsH, 32); + assert.strictEqual(r.boundsW, 96); + assert.strictEqual(r.placed[0].x, 0); + assert.strictEqual(r.placed[0].y, 0); +}); + +test('pack-shelf: height-sort desc — tall sprite first', () => { + const rects = [ + { alias: 'short', w: 32, h: 16 }, + { alias: 'tall', w: 32, h: 64 }, + { alias: 'mid', w: 32, h: 32 }, + ]; + const r = packShelf(rects, 4096); + // Row 0 contains tall (64) + mid (32) + short (16) — all fit in row height 64 + assert.strictEqual(r.boundsH, 64); + // Tall placed at x=0 + const tall = r.placed.find(p => p.alias === 'tall'); + assert.strictEqual(tall.x, 0); + assert.strictEqual(tall.y, 0); +}); + +test('pack-shelf: row wrap when max-width exceeded', () => { + const rects = [ + { alias: 'a', w: 60, h: 20 }, + { alias: 'b', w: 60, h: 20 }, + ]; + const r = packShelf(rects, 100); + // a fits at (0,0). b needs to wrap to next row. + const a = r.placed.find(p => p.alias === 'a'); + const b = r.placed.find(p => p.alias === 'b'); + assert.strictEqual(a.y, 0); + assert.strictEqual(b.y, 20); // shelf height after row 0 = 20 +}); + +test('pack-shelf: oversize beyond max-size -> error', () => { + const rects = [{ alias: 'huge', w: 200, h: 200 }]; + const r = packShelf(rects, 100); + assert.strictEqual(r.error, 'oversize'); + assert.match(r.message, /max-size/); +}); + +test('pack-shelf: deterministic across runs', () => { + const rects = [ + { alias: 'a', w: 32, h: 32 }, + { alias: 'b', w: 64, h: 48 }, + { alias: 'c', w: 16, h: 16 }, + ]; + const r1 = packShelf(rects, 4096); + const r2 = packShelf(rects, 4096); + assert.deepStrictEqual(r1.placed, r2.placed); +}); + +test('pack-shelf: preserves rect aux fields', () => { + const rects = [{ alias: 'a', w: 32, h: 32, sourceFile: 'A.png' }]; + const r = packShelf(rects, 4096); + assert.strictEqual(r.placed[0].sourceFile, 'A.png'); +});