UNPKG

shelf-pack

Version:

A 2D rectangular bin packing data structure that uses the Shelf Best Height Fit heuristic

461 lines (383 loc) 21.7 kB
'use strict'; var test = require('tap').test; var ShelfPack = require('../.'); test('ShelfPack', function(t) { t.test('batch pack()', function(t) { t.test('batch pack() allocates same height bins on existing shelf', function(t) { var sprite = new ShelfPack(64, 64), bins = [ { id: 'a', width: 10, height: 10 }, { id: 'b', width: 10, height: 10 }, { id: 'c', width: 10, height: 10 } ], expectedResults = [ { id: 'a', x: 0, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, { id: 'b', x: 10, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, { id: 'c', x: 20, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 } ]; var results = sprite.pack(bins); t.deepEqual(results, expectedResults); t.end(); }); t.test('batch pack() allocates larger bins on new shelf', function(t) { var sprite = new ShelfPack(64, 64), bins = [ { id: 'a', width: 10, height: 10 }, { id: 'b', width: 10, height: 15 }, { id: 'c', width: 10, height: 20 } ], expectedResults = [ { id: 'a', x: 0, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, { id: 'b', x: 0, y: 10, w: 10, h: 15, maxw: 10, maxh: 15, refcount: 1 }, { id: 'c', x: 0, y: 25, w: 10, h: 20, maxw: 10, maxh: 20, refcount: 1 } ]; var results = sprite.pack(bins); t.deepEqual(results, expectedResults); t.end(); }); t.test('batch pack() allocates shorter bins on existing shelf, minimizing waste', function(t) { var sprite = new ShelfPack(64, 64), bins = [ { id: 'a', width: 10, height: 10 }, { id: 'b', width: 10, height: 15 }, { id: 'c', width: 10, height: 20 }, { id: 'd', width: 10, height: 9 } ], expectedResults = [ { id: 'a', x: 0, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, { id: 'b', x: 0, y: 10, w: 10, h: 15, maxw: 10, maxh: 15, refcount: 1 }, { id: 'c', x: 0, y: 25, w: 10, h: 20, maxw: 10, maxh: 20, refcount: 1 }, { id: 'd', x: 10, y: 0, w: 10, h: 9, maxw: 10, maxh: 9, refcount: 1 } ]; var results = sprite.pack(bins); t.deepEqual(results, expectedResults); t.end(); }); t.test('batch pack() accepts `w`, `h` for `width`, `height`', function(t) { var sprite = new ShelfPack(64, 64), bins = [ { id: 'a', w: 10, h: 10 }, { id: 'b', w: 10, h: 10 }, { id: 'c', w: 10, h: 10 } ], expectedResults = [ { id: 'a', x: 0, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, { id: 'b', x: 10, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, { id: 'c', x: 20, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 } ]; var results = sprite.pack(bins); t.deepEqual(results, expectedResults); t.end(); }); t.test('batch pack() adds `x`, `y` properties to bins with `inPlace` option', function(t) { var sprite = new ShelfPack(64, 64), bins = [ { id: 'a', w: 10, h: 10 }, { id: 'b', w: 10, h: 10 }, { id: 'c', w: 10, h: 10 } ], expectedResults = [ { id: 'a', x: 0, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, { id: 'b', x: 10, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, { id: 'c', x: 20, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 } ], expectedBins = [ { id: 'a', w: 10, h: 10, x: 0, y: 0 }, { id: 'b', w: 10, h: 10, x: 10, y: 0 }, { id: 'c', w: 10, h: 10, x: 20, y: 0 } ]; var results = sprite.pack(bins, { inPlace: true }); t.deepEqual(results, expectedResults); t.deepEqual(bins, expectedBins); t.end(); }); t.test('batch pack() skips bins if not enough room', function(t) { var sprite = new ShelfPack(20, 20), bins = [ { id: 'a', w: 10, h: 10 }, { id: 'b', w: 10, h: 10 }, { id: 'c', w: 10, h: 30 }, // should skip { id: 'd', w: 10, h: 10 } ], expectedResults = [ { id: 'a', x: 0, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, { id: 'b', x: 10, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, { id: 'd', x: 0, y: 10, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 } ], expectedBins = [ { id: 'a', w: 10, h: 10, x: 0, y: 0 }, { id: 'b', w: 10, h: 10, x: 10, y: 0 }, { id: 'c', w: 10, h: 30 }, { id: 'd', w: 10, h: 10, x: 0, y: 10 } ]; var results = sprite.pack(bins, { inPlace: true }); t.deepEqual(results, expectedResults); t.deepEqual(bins, expectedBins); t.end(); }); t.test('batch pack() results in minimal sprite width and height', function(t) { var bins = [ { id: 'a', width: 10, height: 10 }, { id: 'b', width: 5, height: 15 }, { id: 'c', width: 25, height: 15 }, { id: 'd', width: 10, height: 20 } ]; var sprite = new ShelfPack(10, 10, { autoResize: true }); sprite.pack(bins); // Since shelf-pack doubles width/height when packing bins one by one // (first width, then height) this would result in a 50x60 sprite here. // But this can be shrunk to a 30x45 sprite. t.same([sprite.w, sprite.h], [30, 45]); t.end(); }); t.end(); }); t.test('packOne()', function(t) { t.test('packOne() allocates bins with numeric id', function(t) { var sprite = new ShelfPack(64, 64); var bin = sprite.packOne(10, 10, 1000); t.deepEqual(bin, { id: 1000, x: 0, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, 'packed bin 1000'); t.deepEqual(bin, sprite.getBin(1000), 'got bin 1000'); t.end(); }); t.test('packOne() allocates bins with string id', function(t) { var sprite = new ShelfPack(64, 64); var bin = sprite.packOne(10, 10, 'foo'); t.deepEqual(bin, { id: 'foo', x: 0, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, 'packed bin "foo"'); t.deepEqual(bin, sprite.getBin('foo'), 'got bin "foo"'); t.end(); }); t.test('packOne() generates incremental numeric ids, if id not provided', function(t) { var sprite = new ShelfPack(64, 64); var bin1 = sprite.packOne(10, 10); var bin2 = sprite.packOne(10, 10); t.deepEqual(bin1.id, 1, 'packed bin 1'); t.deepEqual(bin2.id, 2, 'packed bin 2'); t.end(); }); t.test('packOne() does not generate an id that collides with an existing id', function(t) { var sprite = new ShelfPack(64, 64); var bin1 = sprite.packOne(10, 10, 1); var bin2 = sprite.packOne(10, 10); t.deepEqual(bin1.id, 1, 'packed bin 1'); t.deepEqual(bin2.id, 2, 'packed bin 2'); t.end(); }); t.test('packOne() does not reallocate a bin with existing id', function(t) { var sprite = new ShelfPack(64, 64); var bin1 = sprite.packOne(10, 10, 1000); t.deepEqual(bin1, { id: 1000, x: 0, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, 'bin 1000 refcount 1'); t.deepEqual(bin1, sprite.getBin(1000), 'got bin 1000'); var bin2 = sprite.packOne(10, 10, 1000); t.deepEqual(bin2, { id: 1000, x: 0, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 2 }, 'bin 1000 refcount 2'); t.deepEqual(bin1, bin2, 'bin1 and bin2 are the same bin'); t.end(); }); t.test('packOne() allocates same height bins on existing shelf', function(t) { var sprite = new ShelfPack(64, 64); t.deepEqual(sprite.packOne(10, 10), { id: 1, x: 0, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, 'first 10x10 bin'); t.deepEqual(sprite.packOne(10, 10), { id: 2, x: 10, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, 'second 10x10 bin'); t.deepEqual(sprite.packOne(10, 10), { id: 3, x: 20, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, 'third 10x10 bin'); t.end(); }); t.test('packOne() allocates larger bins on new shelf', function(t) { var sprite = new ShelfPack(64, 64); t.deepEqual(sprite.packOne(10, 10), { id: 1, x: 0, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, 'shelf 1, 10x10 bin'); t.deepEqual(sprite.packOne(10, 15), { id: 2, x: 0, y: 10, w: 10, h: 15, maxw: 10, maxh: 15, refcount: 1 }, 'shelf 2, 10x15 bin'); t.deepEqual(sprite.packOne(10, 20), { id: 3, x: 0, y: 25, w: 10, h: 20, maxw: 10, maxh: 20, refcount: 1 }, 'shelf 3, 10x20 bin'); t.end(); }); t.test('packOne() allocates shorter bins on existing shelf, minimizing waste', function(t) { var sprite = new ShelfPack(64, 64); t.deepEqual(sprite.packOne(10, 10), { id: 1, x: 0, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, 'shelf 1, 10x10 bin'); t.deepEqual(sprite.packOne(10, 15), { id: 2, x: 0, y: 10, w: 10, h: 15, maxw: 10, maxh: 15, refcount: 1 }, 'shelf 2, 10x15 bin'); t.deepEqual(sprite.packOne(10, 20), { id: 3, x: 0, y: 25, w: 10, h: 20, maxw: 10, maxh: 20, refcount: 1 }, 'shelf 3, 10x20 bin'); t.deepEqual(sprite.packOne(10, 9), { id: 4, x: 10, y: 0, w: 10, h: 9, maxw: 10, maxh: 9, refcount: 1 }, 'shelf 1, 10x9 bin'); t.end(); }); t.test('packOne() returns nothing if not enough room', function(t) { var sprite = new ShelfPack(10, 10); t.deepEqual(sprite.packOne(10, 10, 1), { id: 1, x: 0, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, 'first 10x10 bin'); t.notOk(sprite.packOne(10, 10, 2), 'not enough room'); t.notOk(sprite.shelves[0].alloc(10, 10, 2), 'not enough room on shelf'); t.end(); }); t.test('packOne() allocates in free bin if possible', function(t) { var sprite = new ShelfPack(64, 64); sprite.packOne(10, 10, 1); sprite.packOne(10, 10, 2); sprite.packOne(10, 10, 3); var bin2 = sprite.getBin(2); sprite.unref(bin2); t.deepEqual(sprite.freebins.length, 1, 'freebins length 1'); t.deepEqual(sprite.freebins[0], bin2, 'bin2 moved to freebins'); t.deepEqual(sprite.packOne(10, 10, 4), { id: 4, x: 10, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, 'reused 10x10 free bin for bin4'); t.deepEqual(sprite.freebins.length, 0, 'freebins length 0'); t.end(); }); t.test('packOne() allocates new bin in least wasteful free bin', function(t) { var sprite = new ShelfPack(64, 64); sprite.packOne(10, 10, 1); sprite.packOne(10, 15, 2); sprite.packOne(10, 20, 3); sprite.unref(sprite.getBin(1)); sprite.unref(sprite.getBin(2)); sprite.unref(sprite.getBin(3)); t.deepEqual(sprite.freebins.length, 3, 'freebins length 3'); t.deepEqual(sprite.packOne(10, 13, 4), { id: 4, x: 0, y: 10, w: 10, h: 13, maxw: 10, maxh: 15, refcount: 1 }, 'reused free bin for 10x13 bin4'); t.deepEqual(sprite.freebins.length, 2, 'freebins length 2'); t.end(); }); t.test('packOne() avoids free bin if all are more wasteful than packing on a shelf', function(t) { var sprite = new ShelfPack(64, 64); sprite.packOne(10, 10, 1); sprite.packOne(10, 15, 2); sprite.unref(sprite.getBin(2)); t.deepEqual(sprite.freebins.length, 1, 'freebins length 1'); t.deepEqual(sprite.packOne(10, 10, 3), { id: 3, x: 10, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, 'bin3 packs on shelf instead of 10x15 free bin'); t.deepEqual(sprite.freebins.length, 1, 'freebins still length 1'); t.end(); }); t.test('packOne() considers max bin dimensions when reusing a free bin', function(t) { var sprite = new ShelfPack(64, 64); sprite.packOne(10, 10, 1); sprite.packOne(10, 15, 2); sprite.unref(sprite.getBin(2)); t.deepEqual(sprite.freebins.length, 1, 'freebins length 1'); t.deepEqual(sprite.packOne(10, 13, 3), { id: 3, x: 0, y: 10, w: 10, h: 13, maxw: 10, maxh: 15, refcount: 1 }, 'reused free bin for 10x13 bin3'); t.deepEqual(sprite.freebins.length, 0, 'freebins length 0'); sprite.unref(sprite.getBin(3)); t.deepEqual(sprite.freebins.length, 1, 'freebins length back to 1'); t.deepEqual(sprite.packOne(10, 14, 4), { id: 4, x: 0, y: 10, w: 10, h: 14, maxw: 10, maxh: 15, refcount: 1 }, 'reused free bin for 10x14 bin4'); t.deepEqual(sprite.freebins.length, 0, 'freebins length back to 0'); t.end(); }); t.end(); }); t.test('getBin()', function(t) { t.test('getBin() returns undefined if Bin not found', function(t) { var sprite = new ShelfPack(64, 64); t.deepEqual(sprite.getBin(1), undefined, 'undefined bin'); t.end(); }); t.test('getBin() gets a Bin by numeric id', function(t) { var sprite = new ShelfPack(64, 64); var bin = sprite.packOne(10, 10, 1); t.deepEqual(sprite.getBin(1), bin, 'Bin 1'); t.end(); }); t.test('getBin() gets a Bin by string id', function(t) { var sprite = new ShelfPack(64, 64); var bin = sprite.packOne(10, 10, 'foo'); t.deepEqual(sprite.getBin('foo'), bin, 'Bin "foo"'); t.end(); }); t.end(); }); t.test('ref()', function(t) { t.test('ref() increments the Bin refcount and updates stats', function(t) { var sprite = new ShelfPack(64, 64); var bin1 = sprite.packOne(10, 10, 1); t.deepEqual(bin1.refcount, 1, 'Bin1 refcount is 1'); t.deepEqual(sprite.stats, { 10: 1 }, 'one bin of height 10'); t.deepEqual(sprite.ref(bin1), 2, 'Bin1 refcount is 2'); t.deepEqual(sprite.stats, { 10: 1 }, 'still one bin of height 10'); var bin2 = sprite.packOne(10, 10, 2); t.deepEqual(bin2.refcount, 1, 'Bin2 refcount is 1'); t.deepEqual(sprite.stats, { 10: 2 }, 'two bins of height 10'); t.deepEqual(sprite.ref(bin2), 2, 'Bin2 refcount is 2'); t.deepEqual(sprite.stats, { 10: 2 }, 'still two bins of height 10'); var bin3 = sprite.packOne(10, 15, 3); t.deepEqual(bin3.refcount, 1, 'Bin3 refcount is 1'); t.deepEqual(sprite.stats, { 10: 2, 15: 1}, 'two bins of height 10, one bin of height 15'); t.deepEqual(sprite.ref(bin3), 2, 'Bin3 refcount is 2'); t.deepEqual(sprite.stats, { 10: 2, 15: 1}, 'still two bins of height 10, one bin of height 15'); t.end(); }); t.end(); }); t.test('unref()', function(t) { t.test('unref() decrements the Bin refcount and updates stats', function(t) { var sprite = new ShelfPack(64, 64); // setup.. var bin1 = sprite.packOne(10, 10, 1); sprite.ref(bin1); var bin2 = sprite.packOne(10, 10, 2); sprite.ref(bin2); var bin3 = sprite.packOne(10, 15, 3); sprite.ref(bin3); t.deepEqual(sprite.unref(bin3), 1, 'Bin3 refcount is 1'); t.deepEqual(sprite.stats, { 10: 2, 15: 1}, 'two bins of height 10, one bin of height 15'); t.deepEqual(sprite.freebins.length, 0, 'freebins empty'); t.deepEqual(sprite.unref(bin3), 0, 'Bin3 refcount is 0'); t.deepEqual(sprite.stats, { 10: 2, 15: 0}, 'two bins of height 10, no bins of height 15'); t.deepEqual(sprite.freebins.length, 1, 'freebins length 1'); t.deepEqual(sprite.freebins[0], bin3, 'bin3 moved to freebins'); t.deepEqual(sprite.getBin(3), undefined, 'getBin for Bin3 returns undefined'); t.deepEqual(sprite.unref(bin2), 1, 'Bin2 refcount is 1'); t.deepEqual(sprite.stats, { 10: 2, 15: 0}, 'still two bins of height 10, no bins of height 15'); t.deepEqual(sprite.unref(bin2), 0, 'Bin2 refcount is 0'); t.deepEqual(sprite.stats, { 10: 1, 15: 0}, 'one bin of height 10, no bins of height 15'); t.deepEqual(sprite.freebins.length, 2, 'freebins length 2'); t.deepEqual(sprite.freebins[1], bin2, 'bin2 moved to freebins'); t.deepEqual(sprite.getBin(2), undefined, 'getBin for Bin2 returns undefined'); t.end(); }); t.test('unref() does nothing if refcount is already 0', function(t) { var sprite = new ShelfPack(64, 64); var bin = sprite.packOne(10, 10, 1); t.deepEqual(sprite.unref(bin), 0, 'Bin3 refcount is 0'); t.deepEqual(sprite.stats, { 10: 0}, 'no bins of height 10'); t.deepEqual(sprite.unref(bin), 0, 'Bin3 refcount is still 0'); t.deepEqual(sprite.stats, { 10: 0}, 'still no bins of height 10'); t.end(); }); t.end(); }); t.test('clear()', function(t) { t.test('clear() succeeds', function(t) { var sprite = new ShelfPack(10, 10); t.deepEqual(sprite.packOne(10, 10), { id: 1, x: 0, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, 'first 10x10 bin'); t.notOk(sprite.packOne(10, 10), 'not enough room'); sprite.clear(); t.deepEqual(sprite.packOne(10, 10), { id: 1, x: 0, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, 'first 10x10 bin'); t.end(); }); t.end(); }); t.test('resize()', function(t) { t.test('resize larger succeeds', function(t) { var sprite = new ShelfPack(10, 10); t.deepEqual(sprite.packOne(10, 10), { id: 1, x: 0, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, 'first 10x10 bin'); t.ok(sprite.resize(20, 10)); t.deepEqual(sprite.packOne(10, 10), { id: 2, x: 10, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, 'second 10x10 bin'); t.ok(sprite.resize(20, 20)); t.deepEqual(sprite.packOne(10, 10), { id: 3, x: 0, y: 10, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, 'third 10x10 bin'); t.end(); }); t.test('autoResize grows sprite dimensions by width then height', function(t) { var sprite = new ShelfPack(10, 10, { autoResize: true }); t.deepEqual(sprite.packOne(10, 10), { id: 1, x: 0, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, 'first 10x10 bin'); t.same([sprite.w, sprite.h], [10, 10]); t.deepEqual(sprite.packOne(10, 10), { id: 2, x: 10, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, 'second 10x10 bin'); t.same([sprite.w, sprite.h], [20, 10]); t.deepEqual(sprite.packOne(10, 10), { id: 3, x: 0, y: 10, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, 'third 10x10 bin'); t.same([sprite.w, sprite.h], [20, 20]); t.deepEqual(sprite.packOne(10, 10), { id: 4, x: 10, y: 10, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, 'fourth 10x10 bin'); t.same([sprite.w, sprite.h], [20, 20]); t.deepEqual(sprite.packOne(10, 10), { id: 5, x: 20, y: 0, w: 10, h: 10, maxw: 10, maxh: 10, refcount: 1 }, 'fifth 10x10 bin'); t.same([sprite.w, sprite.h], [40, 20]); t.end(); }); t.test('autoResize accomodates big bin requests', function(t) { var sprite = new ShelfPack(10, 10, { autoResize: true }); t.deepEqual(sprite.packOne(20, 10), { id: 1, x: 0, y: 0, w: 20, h: 10, maxw: 20, maxh: 10, refcount: 1 }, '20x10 bin'); t.same([sprite.w, sprite.h], [40, 10]); t.deepEqual(sprite.packOne(10, 40), { id: 2, x: 0, y: 10, w: 10, h: 40, maxw: 10, maxh: 40, refcount: 1 }, '40x10 bin'); t.same([sprite.w, sprite.h], [40, 80]); t.end(); }); t.end(); }); t.end(); });