feat(pack-shelf): height-sort desc shelf packing with oversize loud-error
This commit is contained in:
54
src/pack-shelf.js
Normal file
54
src/pack-shelf.js
Normal file
@@ -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 };
|
||||||
71
tests/pack-shelf.test.js
Normal file
71
tests/pack-shelf.test.js
Normal file
@@ -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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user