diff --git a/README.md b/README.md index fe01ff5..ab52383 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ Creates a new Packer. maxWidth and maxHeight are passed on to all bins. If ```pa - `options.tag` allow tag based group packing. (default is `false`) - `options.exclusiveTag` tagged rects will have dependent bin, if set to `false`, packer will try to put tag rects into the same bin (default is `true`) - `options.border` atlas edge spacing (default is 0) +- `options.logic` how to fill the rects. There are three options: 0 (max area), 1 (max edge), 2 (fillWidth). Default is 1 (max edge) #### ```packer.add(width, height, data)``` +1 overload @@ -136,6 +137,18 @@ If `options.tag` is set to `true`, packer will check if the input object has `ta Normally all bins are of equal size or smaller than ```maxWidth```/```maxHeight```. If a rect is added that individually does not fit into those constraints a special bin will be created. This bin will only contain a single rect with a special "oversized" flag. This can be handled further on in the chain by displaying an error/warning or by simply ignoring it. +## Packing logic + +`options.logic` allows to change the method on how the algorithm selects the free spaces. There are three options: + +`{option.logic = 0}` Logic is MAX_AREA, selects the free space with the smallest loss of area. + +`{option.logic = 1}` Logic is MAX_EDGE, is default and selects the free space with the smallest loss of either width or height. + +`{option.logic = 2}` Logic is FILL_WIDTH, fills the complete width first before placing elements in next row. To get the used height `bin.height` only gives correct values with options: `{pot: false, square: false}`. Best results also with `option.allowRotation = true` + + + ## Packing algorithm Use Max Rectangle Algorithm for packing, same as famous **Texture Packer** diff --git a/src/maxrects-bin.ts b/src/maxrects-bin.ts index ca93439..8b54cff 100644 --- a/src/maxrects-bin.ts +++ b/src/maxrects-bin.ts @@ -151,7 +151,7 @@ export class MaxRectsBin extends Bin { i++; } this.pruneFreeList(); - this.verticalExpand = this.width > this.height ? true : false; + this.verticalExpand = this.options.logic === PACKING_LOGIC.FILL_WIDTH ? false : this.width > this.height ? true : false; rect.x = node.x; rect.y = node.y; if (rect.rot === undefined) rect.rot = false; @@ -182,7 +182,16 @@ export class MaxRectsBin extends Bin { return undefined; } + /** + * Find the best rect out of freeRects + * This method has different logics to resolve the best rect. + * @param width + * @param height + * @param allowRotation + * @returns Rectangle of the best rect for placement + */ private findNode (width: number, height: number, allowRotation?: boolean): Rectangle | undefined { + // scoring based on one single number. The smaller the better the choice. let score: number = Number.MAX_VALUE; let areaFit: number; let r: Rectangle; @@ -190,9 +199,25 @@ export class MaxRectsBin extends Bin { for (let i in this.freeRects) { r = this.freeRects[i]; if (r.width >= width && r.height >= height) { - areaFit = (this.options.logic === PACKING_LOGIC.MAX_AREA) ? - r.width * r.height - width * height : - Math.min(r.width - width, r.height - height); + if (this.options.logic === PACKING_LOGIC.MAX_AREA) { + // the rect with the lowest rest area wins + areaFit = r.width * r.height - width * height; + } else if (this.options.logic === PACKING_LOGIC.FILL_WIDTH) { + // this logic needs to factors to build a score. + // 1. rect position: choose the most rightest one with the lowest y coordinate. + // 2. size that needs to grow to place the element. The lower the better score (leads to optimal rotation placement) + + const currentRectPositionScore = r.x + r.y * this.maxWidth; // each y value adds a full width to the score to balance x and y coordinates to a single number + const numberOfBetterRects = this.freeRects.filter(rect => (rect.x + rect.y * this.maxWidth) < currentRectPositionScore).length; // search if there are rects, righter and a lower y coordinate. + + // calculate how much space will be add to total height + const heightToGain = r.y + height - this.height; + + areaFit = numberOfBetterRects + heightToGain; // add both factors together + } else { + // the rect with the lowest loss of either width or height wins + areaFit = Math.min(r.width - width, r.height - height); + } if (areaFit < score) { bestNode = new Rectangle(width, height, r.x, r.y); score = areaFit; @@ -203,9 +228,19 @@ export class MaxRectsBin extends Bin { // Continue to test 90-degree rotated rectangle if (r.width >= height && r.height >= width) { - areaFit = (this.options.logic === PACKING_LOGIC.MAX_AREA) ? - r.width * r.height - height * width : - Math.min(r.height - width, r.width - height); + if (this.options.logic === PACKING_LOGIC.MAX_AREA) { + areaFit = r.width * r.height - height * width; + } else if (this.options.logic === PACKING_LOGIC.FILL_WIDTH) { + const currentRectPositionScore = r.x + r.y * this.maxWidth; + const numberOfBetterRects = this.freeRects.filter(rect => (rect.x + rect.y * this.maxWidth) < currentRectPositionScore).length; // search if there are rects, righter and a lower y coordinate. + + // calculate how much space will be add to total height + const heightToGain = r.y + width - this.height; + + areaFit = numberOfBetterRects + heightToGain; // add both factors together + } else { + areaFit = Math.min(r.height - width, r.width - height); + } if (areaFit < score) { bestNode = new Rectangle(height, width, r.x, r.y, true); // Rotated node score = areaFit; diff --git a/src/maxrects-packer.ts b/src/maxrects-packer.ts index 505ea5b..f78a43a 100644 --- a/src/maxrects-packer.ts +++ b/src/maxrects-packer.ts @@ -7,7 +7,8 @@ export const EDGE_MAX_VALUE: number = 4096; export const EDGE_MIN_VALUE: number = 128; export enum PACKING_LOGIC { MAX_AREA = 0, - MAX_EDGE = 1 + MAX_EDGE = 1, + FILL_WIDTH = 2, } /** @@ -110,7 +111,7 @@ export class MaxRectsPacker { if (args.length === 1) { if (typeof args[0] !== 'object') throw new Error("MacrectsPacker.add(): Wrong parameters"); const rect = args[0] as T; - if (rect.width > this.width || rect.height > this.height) { + if (!((rect.width <= this.width && rect.height <= this.height) || (this.options.allowRotation && rect.width <= this.height && rect.height <= this.width))) { this.bins.push(new OversizedElementBin(rect)); } else { let added = this.bins.slice(this._currentBinIndex).find(bin => bin.add(rect) !== undefined); @@ -127,7 +128,7 @@ export class MaxRectsPacker { const rect: IRectangle = new Rectangle(args[0], args[1]); if (args.length > 2) rect.data = args[2]; - if (rect.width > this.width || rect.height > this.height) { + if (!((rect.width <= this.width && rect.height <= this.height) || (this.options.allowRotation && rect.width <= this.height && rect.height <= this.width))) { this.bins.push(new OversizedElementBin(rect as T)); } else { let added = this.bins.slice(this._currentBinIndex).find(bin => bin.add(rect as T) !== undefined); diff --git a/test/maxrects-bin.spec.js b/test/maxrects-bin.spec.js index fe2f5cf..dfabfd1 100644 --- a/test/maxrects-bin.spec.js +++ b/test/maxrects-bin.spec.js @@ -348,3 +348,72 @@ describe("border", () => { } }); }); + +describe("logic FILL_WIDTH", () => { + beforeEach(() => { + bin = new MaxRectsBin(1024, 512, 0, {allowRotation: true, logic: 2, pot: false, square: false}); + }); + + test("sets all elements along width with the smallest height", () => { + /** + * Visualize the placement result + * _______________________ + * | ███ ███ ███ | + * ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ + */ + + let position1 = bin.add(300, 50, {}); + let position2 = bin.add(50, 300, {}); + let position3 = bin.add(300, 50, {}); + expect(position1.x).toBe(0); + expect(position1.y).toBe(0); + expect(position2.x).toBe(300); + expect(position2.y).toBe(0); + expect(position3.x).toBe(600); + expect(position3.y).toBe(0); + expect(bin.width).toBe(900); + expect(bin.height).toBe(50); + }); + + test("adds rects correctly with rotation", () => { + /** + * Visualize the placement result (1 vertical at the end) + * _______________________ + * | ███ ███ ███ █ | + * | ███ ███ ███ █ | + * | ███ ███ ███ █ | + * | ██████ | + * ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ + */ + + const rects = [ + [300, 100], + [100, 300], + [300, 100], + [300, 100], + [100, 300], + [300, 100], + [300, 100], + [100, 300], + [300, 100], + [300, 100], + [300, 100], + [100, 600], + ] + rects.forEach(rect => bin.add(rect[0], rect[1])); + expect([bin.rects[0].x, bin.rects[0].y]).toEqual([0, 0]); + expect([bin.rects[1].x, bin.rects[1].y]).toEqual([300, 0]); + expect([bin.rects[2].x, bin.rects[2].y]).toEqual([600, 0]); + expect([bin.rects[3].x, bin.rects[3].y]).toEqual([0, 100]); + expect([bin.rects[4].x, bin.rects[4].y]).toEqual([300, 100]); + expect([bin.rects[5].x, bin.rects[5].y]).toEqual([600, 100]); + expect([bin.rects[6].x, bin.rects[6].y]).toEqual([0, 200]); + expect([bin.rects[7].x, bin.rects[7].y]).toEqual([300, 200]); + expect([bin.rects[8].x, bin.rects[8].y]).toEqual([600, 200]); + expect([bin.rects[9].x, bin.rects[9].y]).toEqual([900, 0]); + expect([bin.rects[10].x, bin.rects[10].y]).toEqual([0, 300]); + expect(bin.width).toBe(1000); + expect(bin.height).toBe(400); + }); + +}); \ No newline at end of file diff --git a/test/maxrects-packer.spec.js b/test/maxrects-packer.spec.js index 618c72f..c0b0ebf 100644 --- a/test/maxrects-packer.spec.js +++ b/test/maxrects-packer.spec.js @@ -126,6 +126,23 @@ describe("#add", () => { expect(packer.bins[1].rects[0].width).toBe(2000); expect(packer.bins[1].rects[0].oversized).toBe(true); }); + + test("checks oversized elements rotation and adds rotated", () => { + const packer = new MaxRectsPacker(512, 1024, 0, {...opt, allowRotation: true}); + packer.add(640, 256, {num: 1}); + expect(packer.bins.length).toBe(1); + expect(packer.bins[0].rects[0].width).toBe(256); + expect(packer.bins[0].rects[0].height).toBe(640); + expect(packer.bins[0].rects[0].oversized).toBe(false); + }); + + test("checks oversized elements and skip rotation when set to false", () => { + const packer = new MaxRectsPacker(512, 1024, 0, {...opt, allowRotation: false}); + packer.add(640, 256, {num: 1}); + expect(packer.bins.length).toBe(1); + expect(packer.bins[0].rects[0].width).toBe(640); + expect(packer.bins[0].rects[0].oversized).toBe(true); + }); }); describe("#sort", () => {