├── assets ├── favicon.ico ├── favicon.png ├── preview.png ├── favicon32.png ├── custom.css └── custom.js ├── .npmignore ├── .vscode ├── settings.json ├── tasks.json └── launch.json ├── src ├── index.ts ├── types.ts ├── abstract-bin.ts ├── oversized-element-bin.ts ├── geom │ └── Rectangle.ts ├── maxrects-packer.ts └── maxrects-bin.ts ├── .travis.yml ├── typedoc.json ├── tsconfig.build.json ├── jest.config.js ├── TODO ├── rollup.config.js ├── LICENSE ├── .github └── workflows │ ├── node.js.yml │ └── release.yml ├── test ├── oversized-element-bin.spec.js ├── generictype.spec.js ├── rectangle.spec.js ├── efficiency.spec.js ├── maxrects-packer.spec.js └── maxrects-bin.spec.js ├── .gitignore ├── eslint.config.js ├── UPGRADE_SUMMARY.md ├── package.json ├── tsconfig.json ├── CHANGELOG.md ├── .eslintrc.json └── README.md /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soimy/maxrects-packer/HEAD/assets/favicon.ico -------------------------------------------------------------------------------- /assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soimy/maxrects-packer/HEAD/assets/favicon.png -------------------------------------------------------------------------------- /assets/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soimy/maxrects-packer/HEAD/assets/preview.png -------------------------------------------------------------------------------- /assets/favicon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soimy/maxrects-packer/HEAD/assets/favicon32.png -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .tmp 2 | output 3 | node_modules 4 | .DS_Store 5 | coverage 6 | .eslintrc 7 | tslint.json 8 | .gitignore 9 | *.png 10 | .vscode 11 | docs 12 | test 13 | jest.config.js 14 | rollup.config.js 15 | .travis.yml 16 | TODO -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.minimap.enabled": true, 3 | "spellright.language": [ 4 | "en" 5 | ], 6 | "spellright.documentTypes": [ 7 | "markdown", 8 | "latex", 9 | "plaintext" 10 | ] 11 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Rectangle, IRectangle } from "./geom/Rectangle"; 2 | export { MaxRectsPacker, PACKING_LOGIC, IOption } from "./maxrects-packer"; 3 | export { Bin, IBin } from "./abstract-bin"; 4 | export { MaxRectsBin } from "./maxrects-bin"; 5 | export { OversizedElementBin } from "./oversized-element-bin"; 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "stable" 5 | 6 | before_install: 7 | - npm update 8 | 9 | install: 10 | - npm install 11 | 12 | cache: 13 | directories: 14 | - "node_modules" 15 | 16 | script: 17 | - npm run cover 18 | 19 | # Send coverage data to Coveralls 20 | after_script: "cat test/coverage/lcov.info | node_modules/coveralls/bin/coveralls.js" 21 | 22 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": ["src/index.ts"], 3 | "entryPointStrategy": "expand", 4 | "out": "docs", 5 | "excludeExternals": true, 6 | "excludeNotDocumented": false, 7 | "excludePrivate": false, 8 | "skipErrorChecking": true, 9 | "tsconfig": "tsconfig.json", 10 | "plugin": ["typedoc-unhoax-theme"], 11 | "theme": "unhoax", 12 | "customCss": "./assets/custom.css", 13 | "customJs": "./assets/custom.js" 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "build:clean", 9 | "label": "build", 10 | "problemMatcher": [ "$tsc" ], 11 | "group": { 12 | "kind": "build", 13 | "isDefault": true 14 | } 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "module": "esnext", 6 | "declaration": true, 7 | "declarationDir": "./lib/types", 8 | "outDir": "./lib", 9 | "removeComments": true, 10 | "types": [], 11 | "forceConsistentCasingInFileNames": true 12 | }, 13 | "include": [ 14 | "./src/**/*" 15 | ], 16 | "exclude": [ 17 | "./test/**/*", 18 | "./node_modules", 19 | "./lib", 20 | "./dist" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /assets/custom.css: -------------------------------------------------------------------------------- 1 | /* Custom CSS for TypeDoc with Favicon */ 2 | 3 | /* Add favicon to the site title in header */ 4 | .header-logo::before { 5 | content: ""; 6 | display: inline-block; 7 | width: 24px; 8 | height: 24px; 9 | background-image: url('./favicon.png'); 10 | background-size: contain; 11 | background-repeat: no-repeat; 12 | margin-right: 8px; 13 | vertical-align: middle; 14 | } 15 | 16 | /* Ensure the title container can accommodate the favicon */ 17 | .header-logo { 18 | display: flex; 19 | align-items: center; 20 | } 21 | 22 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | extensionsToTreatAsEsm: ['.ts'], 5 | moduleNameMapper: { 6 | '^(\\.{1,2}/.*)\\.js$': '$1' 7 | }, 8 | transform: { 9 | '^.+\\.ts$': ['ts-jest', { 10 | useESM: true 11 | }] 12 | }, 13 | verbose: true, 14 | coverageDirectory: './test/coverage', 15 | collectCoverageFrom: [ 16 | 'src/**/*.ts', 17 | '!src/**/*.d.ts' 18 | ], 19 | // 确保源码映射支持 20 | collectCoverage: true, 21 | coverageReporters: ['text', 'lcov', 'html'], 22 | // 添加源码映射支持 23 | setupFilesAfterEnv: [], 24 | testMatch: ['/test/**/*.spec.js'] 25 | }; 26 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible Node.js debug attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Jest Tests", 11 | "program": "${workspaceFolder}/node_modules/jest/bin/jest", 12 | "args": [ 13 | "--runInBand", 14 | ], 15 | "preLaunchTask": "build", 16 | "internalConsoleOptions": "openOnSessionStart" 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | ✔ 用Rollup一体化打包 @done(19-05-26 14:45) 2 | ✔ 测试引擎Mocha->Jest @done(19-05-26 14:45) 3 | ✔ 更新Covers 从Istanble->Jest @done(19-05-26 14:45) 4 | ✔ 更新README.md @done(19-05-26 17:34) 5 | ✔ Test增加Generic type部分 @done(19-05-26 17:04) 6 | ✔ 发布2.1.0版 @done(19-05-26 18:20) 7 | ✔ 增加Bin.border控制参数 @started(19-06-11 18:08) @done(19-06-11 18:31) @lasted(23m35s) 8 | ✔ 修正Bin.border错误 @started(19-06-11 21:56) @done(19-06-12 02:56) @lasted(5h51s) 9 | ✔ 完善border部分test @done(19-06-12 13:15) 10 | ✔ `hash`二次排序 @done(19-06-04 15:39) 11 | ☐ 完善注释和文档 12 | ✔ 发布gh-pages @done(19-06-04 15:39) 13 | ✔ 增加翻片儿`next()` @done(19-06-04 15:39) 14 | ✔ 根据tag自动分配Bin @done(19-06-06 00:32) @lasted(12s) 15 | ✔ 增加isDirty状态 @done(19-06-12 16:17) 16 | ✔ MaxRectsBin.isDirty @done(19-06-11 18:08) 17 | ✔ Rectangle.isDirty @started(19-06-12 13:15) @done(19-06-12 16:17) @lasted(3h2m6s) 18 | ✔ reset/repack功能 @started(19-06-13 14:12) @done(19-06-14 14:37) @lasted(1d25m33s) 19 | ✔ 增加test @done(19-06-14 14:37) -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from "rollup-plugin-typescript2"; 2 | import terser from "@rollup/plugin-terser"; 3 | 4 | const config = [ 5 | { 6 | input: "./src/index.ts", 7 | // transpiled typescript in umd and es format 8 | output: [ 9 | { file: "dist/maxrects-packer.js", name: "MaxRectsPacker", format: "umd", sourcemap: true }, 10 | { file: "dist/maxrects-packer.mjs", format: "es", sourcemap: true } 11 | ], 12 | plugins: [ typescript({ 13 | tsconfig: "./tsconfig.build.json" 14 | })] 15 | }, 16 | { 17 | input: "./src/index.ts", 18 | // uglified transpiled typescript in commonjs 19 | output: [ 20 | { file: "dist/maxrects-packer.min.js", format: "cjs", sourcemap: false } 21 | ], 22 | plugins: [ terser(), typescript({ 23 | tsconfig: "./tsconfig.build.json" 24 | }) ] 25 | } 26 | ]; 27 | export default config; 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Shen Yiming 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export const EDGE_MAX_VALUE: number = 4096; 2 | export const EDGE_MIN_VALUE: number = 128; 3 | 4 | export enum PACKING_LOGIC { 5 | MAX_AREA = 0, 6 | MAX_EDGE = 1, 7 | FILL_WIDTH = 2, 8 | } 9 | 10 | /** 11 | * Options for MaxRect Packer 12 | * 13 | * @property smart - Smart sizing packer (default is true) 14 | * @property pot - use power of 2 sizing (default is true) 15 | * @property square - use square size (default is false) 16 | * @property allowRotation - allow rotation packing (default is false) 17 | * @property tag - allow auto grouping based on `rect.tag` (default is false) 18 | * @property 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) 19 | * @property border - atlas edge spacing (default is 0) 20 | * @property logic - MAX_AREA or MAX_EDGE based sorting logic (default is MAX_EDGE) 21 | */ 22 | export interface IOption { 23 | smart?: boolean 24 | pot?: boolean 25 | square?: boolean 26 | allowRotation?: boolean 27 | tag?: boolean 28 | exclusiveTag?: boolean 29 | border?: number 30 | logic?: PACKING_LOGIC 31 | } 32 | -------------------------------------------------------------------------------- /assets/custom.js: -------------------------------------------------------------------------------- 1 | // Custom JavaScript for TypeDoc to add favicon 2 | (function() { 3 | 'use strict'; 4 | 5 | // Add favicon to document head 6 | function addFavicon() { 7 | // Add ICO favicon 8 | var icoFavicon = document.createElement('link'); 9 | icoFavicon.rel = 'icon'; 10 | icoFavicon.type = 'image/x-icon'; 11 | icoFavicon.href = './favicon.ico'; 12 | document.head.appendChild(icoFavicon); 13 | 14 | // Add PNG favicon 15 | var pngFavicon = document.createElement('link'); 16 | pngFavicon.rel = 'icon'; 17 | pngFavicon.type = 'image/png'; 18 | pngFavicon.href = './favicon.png'; 19 | document.head.appendChild(pngFavicon); 20 | 21 | // Add Apple touch icon 22 | var appleTouchIcon = document.createElement('link'); 23 | appleTouchIcon.rel = 'apple-touch-icon'; 24 | appleTouchIcon.href = './favicon.png'; 25 | document.head.appendChild(appleTouchIcon); 26 | } 27 | 28 | // Run when DOM is loaded 29 | if (document.readyState === 'loading') { 30 | document.addEventListener('DOMContentLoaded', addFavicon); 31 | } else { 32 | addFavicon(); 33 | } 34 | })(); 35 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | test: 14 | name: Test and Coverage 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [20.x, 22.x, 24.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 2 27 | 28 | - name: Use Node.js ${{ matrix.node-version }} 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: ${{ matrix.node-version }} 32 | cache: 'npm' 33 | 34 | - run: npm ci 35 | - run: npm run cover 36 | - name: Upload coverage reports to Codecov 37 | uses: codecov/codecov-action@v5 38 | if: matrix.node-version == '24.x' 39 | with: 40 | token: ${{ secrets.CODECOV_TOKEN }} 41 | -------------------------------------------------------------------------------- /src/abstract-bin.ts: -------------------------------------------------------------------------------- 1 | import { IRectangle, Rectangle } from "./geom/Rectangle"; 2 | import { IOption } from "./types"; 3 | 4 | export interface IBin { 5 | width: number 6 | height: number 7 | maxWidth: number 8 | maxHeight: number 9 | freeRects: IRectangle[] 10 | rects: IRectangle[] 11 | options: IOption 12 | [propName: string]: any 13 | } 14 | 15 | export abstract class Bin implements IBin { 16 | public width!: number; 17 | public height!: number; 18 | public maxWidth!: number; 19 | public maxHeight!: number; 20 | public freeRects!: IRectangle[]; 21 | public rects!: T[]; 22 | public options!: IOption; 23 | public abstract add (rect: T): T | undefined; 24 | public abstract add (width: number, height: number, data: any): T | undefined; 25 | public abstract reset (deepRest: boolean): void; 26 | public abstract repack (): T[] | undefined; 27 | 28 | public data?: any; 29 | public tag?: string; 30 | 31 | protected _dirty: number = 0; 32 | get dirty (): boolean { return this._dirty > 0 || this.rects.some(rect => rect.dirty); } 33 | /** 34 | * Set bin dirty status 35 | */ 36 | public setDirty (value: boolean = true): void { 37 | this._dirty = value ? this._dirty + 1 : 0; 38 | if (!value) { 39 | for (let rect of this.rects) { 40 | if (rect.setDirty) rect.setDirty(false); 41 | } 42 | } 43 | } 44 | 45 | public abstract clone (): Bin; 46 | } 47 | -------------------------------------------------------------------------------- /test/oversized-element-bin.spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | let OversizedElementBin = require("../src/oversized-element-bin").OversizedElementBin; 4 | let Rectangle = require("../src/geom/Rectangle").Rectangle; 5 | 6 | const oversizedRect = new Rectangle(2000, 2000); 7 | oversizedRect.data = {foo: "bar"}; 8 | 9 | describe("OversizedElementBin", () => { 10 | test("stores data correctly", () => { 11 | let bin = new OversizedElementBin(2000, 2000, {foo: "bar"}); 12 | expect(bin.width).toBe(2000); 13 | expect(bin.height).toBe(2000); 14 | expect(bin.rects[0].x).toBe(0); 15 | expect(bin.rects[0].y).toBe(0); 16 | expect(bin.rects[0].width).toBe(2000); 17 | expect(bin.rects[0].height).toBe(2000); 18 | expect(bin.rects[0].data.foo).toBe("bar"); 19 | expect(bin.rects[0].oversized).toBeTruthy(); 20 | }); 21 | 22 | test("stores data correctly via generic type", () => { 23 | let bin = new OversizedElementBin(oversizedRect); 24 | expect(bin.width).toBe(2000); 25 | expect(bin.height).toBe(2000); 26 | expect(bin.rects[0].x).toBe(0); 27 | expect(bin.rects[0].y).toBe(0); 28 | expect(bin.rects[0].width).toBe(2000); 29 | expect(bin.rects[0].height).toBe(2000); 30 | expect(bin.rects[0].data.foo).toBe("bar"); 31 | expect(bin.rects[0].oversized).toBeTruthy(); 32 | }); 33 | 34 | test("#add returns undefined", () => { 35 | let bin = new OversizedElementBin(2000, 2000, {foo: "bar"}); 36 | expect(bin.add(1, 1, {})).toBeUndefined(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | dist 35 | lib 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # Typescript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | .eslintrc 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | 63 | # Typedocs 64 | docs 65 | 66 | # Mac stuff 67 | # General 68 | .DS_Store 69 | .AppleDouble 70 | .LSOverride 71 | 72 | # Icon must end with two \r 73 | Icon 74 | 75 | # Thumbnails 76 | ._* 77 | 78 | # Files that might appear in the root of a volume 79 | .DocumentRevisions-V100 80 | .fseventsd 81 | .Spotlight-V100 82 | .TemporaryItems 83 | .Trashes 84 | .VolumeIcon.icns 85 | .com.apple.timemachine.donotpresent 86 | 87 | # Directories potentially created on remote AFP share 88 | .AppleDB 89 | .AppleDesktop 90 | Network Trash Folder 91 | Temporary Items 92 | .apdisk 93 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import tseslint from '@typescript-eslint/eslint-plugin'; 3 | import tseslintParser from '@typescript-eslint/parser'; 4 | import importPlugin from 'eslint-plugin-import'; 5 | import jsdocPlugin from 'eslint-plugin-jsdoc'; 6 | 7 | export default [ 8 | js.configs.recommended, 9 | { 10 | files: ['src/**/*.ts'], 11 | languageOptions: { 12 | parser: tseslintParser, 13 | parserOptions: { 14 | ecmaVersion: 'latest', 15 | sourceType: 'module', 16 | project: './tsconfig.json' 17 | } 18 | }, 19 | plugins: { 20 | '@typescript-eslint': tseslint, 21 | 'import': importPlugin, 22 | 'jsdoc': jsdocPlugin 23 | }, 24 | rules: { 25 | // TypeScript rules 26 | '@typescript-eslint/no-unused-vars': 'warn', 27 | '@typescript-eslint/no-explicit-any': 'warn', 28 | '@typescript-eslint/explicit-function-return-type': 'off', 29 | '@typescript-eslint/explicit-module-boundary-types': 'off', 30 | '@typescript-eslint/prefer-const': 'error', 31 | 32 | // Import rules 33 | 'import/order': 'warn', 34 | 35 | // JSDoc rules 36 | 'jsdoc/check-alignment': 'warn', 37 | 'jsdoc/check-indentation': 'warn', 38 | 39 | // General rules 40 | 'indent': ['error', 4], 41 | 'quotes': ['error', 'double'], 42 | 'semi': ['error', 'always'], 43 | 'no-console': 'warn', 44 | 'no-debugger': 'error' 45 | } 46 | }, 47 | { 48 | files: ['test/**/*.js'], 49 | languageOptions: { 50 | ecmaVersion: 'latest', 51 | sourceType: 'module', 52 | globals: { 53 | describe: 'readonly', 54 | it: 'readonly', 55 | expect: 'readonly', 56 | beforeEach: 'readonly', 57 | afterEach: 'readonly' 58 | } 59 | } 60 | } 61 | ]; 62 | -------------------------------------------------------------------------------- /src/oversized-element-bin.ts: -------------------------------------------------------------------------------- 1 | import { IRectangle, Rectangle } from "./geom/Rectangle"; 2 | import { IOption } from "./types"; 3 | import { Bin } from "./abstract-bin"; 4 | 5 | export class OversizedElementBin extends Bin { 6 | public width: number; 7 | public height: number; 8 | public data: any; 9 | public maxWidth: number; 10 | public maxHeight: number; 11 | public options: IOption; 12 | public rects: T[] = []; 13 | public freeRects: IRectangle[]; 14 | 15 | constructor (rect: T); 16 | constructor (width: number, height: number, data: any); 17 | constructor (...args: any[]) { 18 | super(); 19 | if (args.length === 1) { 20 | if (typeof args[0] !== 'object') throw new Error("OversizedElementBin: Wrong parameters"); 21 | const rect = args[0]; 22 | this.rects = [rect]; 23 | this.width = rect.width; 24 | this.height = rect.height; 25 | this.data = rect.data; 26 | rect.oversized = true; 27 | } else { 28 | this.width = args[0]; 29 | this.height = args[1]; 30 | this.data = args.length > 2 ? args[2] : null; 31 | const rect: IRectangle = new Rectangle(this.width, this.height); 32 | rect.oversized = true; 33 | rect.data = this.data; 34 | this.rects.push(rect as T); 35 | } 36 | this.freeRects = []; 37 | this.maxWidth = this.width; 38 | this.maxHeight = this.height; 39 | this.options = { smart: false, pot: false, square: false }; 40 | } 41 | 42 | add () { return undefined; } 43 | reset (deepReset: boolean = false): void { 44 | // nothing to do here 45 | } 46 | repack (): T[] | undefined { return undefined; } 47 | clone(): Bin { 48 | let clonedBin: OversizedElementBin = new OversizedElementBin(this.rects[0]); 49 | return clonedBin; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /test/generictype.spec.js: -------------------------------------------------------------------------------- 1 | let MaxRectsPacker = require('../src/maxrects-packer').MaxRectsPacker; 2 | let Rectangle = require('../src/geom/Rectangle').Rectangle; 3 | 4 | class Block extends Rectangle { 5 | constructor (width = 0, height = 0, x = 0, y = 0, rot = false) { 6 | super(); 7 | this.color = 0xffffffff; // Extended attribution 8 | } 9 | getColor () { // Extended method 10 | return this.color; 11 | } 12 | } 13 | 14 | let packer; 15 | beforeEach(() => { 16 | packer = new MaxRectsPacker(1024, 1024, 0); 17 | }); 18 | 19 | test('generic type extends Rectangle class', () => { 20 | let blocks = []; 21 | blocks.push(new Block(512, 512)); 22 | blocks.push(new Block(512, 512)); 23 | blocks.push(new Block(512, 512)); 24 | let colors = [0x000000ff, 0xff0000ff]; 25 | colors.forEach((color, i) => { 26 | blocks[i].color = color; 27 | }); 28 | 29 | packer.addArray(blocks); 30 | expect(packer.bins.length).toBe(1); 31 | expect(packer.bins[0].rects.length).toBe(blocks.length); 32 | expect(packer.bins[0].rects[0].x).toBe(0); 33 | expect(packer.bins[0].rects[0].y).toBe(0); 34 | expect(packer.bins[0].rects[0].color).toBe(colors[0]); 35 | expect(packer.bins[0].rects[0].getColor()).toBe(colors[0]); 36 | expect(packer.bins[0].rects[2].color).toBe(0xffffffff); 37 | }); 38 | 39 | test('anonymous class with width, height', () => { 40 | let blocks = []; 41 | blocks.push({width: 512, height: 512}); 42 | blocks.push({width: 512, height: 512}); 43 | blocks.push({width: 512, height: 512}); 44 | let colors = [0x000000ff, 0xff0000ff]; 45 | colors.forEach((color, i) => { 46 | blocks[i].color = color; 47 | }); 48 | 49 | packer.addArray(blocks); 50 | expect(packer.bins.length).toBe(1); 51 | expect(packer.bins[0].rects.length).toBe(blocks.length); 52 | expect(packer.bins[0].rects[0].x).toBe(0); 53 | expect(packer.bins[0].rects[0].y).toBe(0); 54 | expect(packer.bins[0].rects[0].color).toBe(colors[0]); 55 | expect(packer.bins[0].rects[2].color).toBeUndefined(); 56 | }); 57 | -------------------------------------------------------------------------------- /UPGRADE_SUMMARY.md: -------------------------------------------------------------------------------- 1 | # DevDependencies 重大更新摘要 2 | 3 | ## 已更新的主要依赖包 4 | 5 | ### TypeScript 工具链 6 | - **TypeScript**: `^4.9.4` → `^5.6.3` (重大更新) 7 | - **ts-jest**: `^29.0.3` → `^29.2.5` 8 | - **ts-node**: `^10.9.1` → `^10.9.2` 9 | 10 | ### 构建工具 11 | - **Rollup**: `^3.9.1` → `^4.24.0` (重大更新) 12 | - **rollup-plugin-dts**: `^5.1.0` → `^6.1.1` (重大更新) 13 | - **rollup-plugin-esbuild**: `^5.0.0` → `^6.1.1` (重大更新) 14 | - **rollup-plugin-typescript2**: `^0.34.1` → `^0.36.0` 15 | - **@rollup/plugin-terser**: `^0.3.0` → `^0.4.4` 16 | - **esbuild**: `^0.16.12` → `^0.24.0` (重大更新) 17 | 18 | ### 代码质量工具 19 | - **ESLint**: `^8.31.0` → `^9.12.0` (重大更新) 20 | - **@typescript-eslint/eslint-plugin**: `^5.47.1` → `^8.8.1` (重大更新) 21 | - **@typescript-eslint/parser**: `^5.47.1` → `^8.8.1` (重大更新) 22 | - **eslint-plugin-import**: `^2.26.0` → `^2.31.0` 23 | - **eslint-plugin-jsdoc**: `^39.6.4` → `^50.3.1` (重大更新) 24 | 25 | ### 测试工具 26 | - **Jest**: `^29.3.1` → `^29.7.0` 27 | - **@types/jest**: `^29.2.5` → `^29.5.12` 28 | 29 | ### 文档工具 30 | - **TypeDoc**: `^0.23.23` → `^0.26.8` (重大更新) 31 | 32 | ### 其他工具 33 | - **rimraf**: `^3.0.2` → `^6.0.1` (重大更新) 34 | - **gh-pages**: `^4.0.0` → `^6.1.1` (重大更新) 35 | - **@types/node**: `^18.11.18` → `^22.7.5` (重大更新) 36 | - **tslib**: `^2.4.1` → `^2.7.0` 37 | 38 | ## 已移除的包 39 | - **tslint-config-standard**: `^9.0.0` (已废弃,改用 ESLint) 40 | - **@typescript-eslint/eslint-plugin-tslint**: `^5.48.0` (不再需要) 41 | - **coveralls**: `^3.1.1` (移除,改用内置覆盖率) 42 | 43 | ## 已添加的包 44 | - **c8**: `^10.1.2` (新的覆盖率工具) 45 | 46 | ## 配置文件更新 47 | 48 | ### 新增文件 49 | - `eslint.config.js` - 现代 ESLint 配置,替代 tslint 50 | 51 | ### 更新的文件 52 | - `tsconfig.json` - 更新到 TypeScript 5.x 标准 53 | - `jest.config.js` - 更新以支持 ESM 和新版本 54 | - `rollup.config.js` - 添加 DTS 支持和现代配置 55 | - `package.json` - 更新脚本和依赖 56 | 57 | ### 删除的文件 58 | - `tslint.json` - 已废弃,使用 ESLint 替代 59 | 60 | ## 新的脚本命令 61 | - `npm run lint` - 运行 ESLint 检查 62 | - `npm run lint:fix` - 自动修复 ESLint 问题 63 | 64 | ## 安全修复 65 | - 修复了 esbuild、gh-pages、form-data、tough-cookie 等包的安全漏洞 66 | - 更新了所有依赖到最新稳定版本 67 | 68 | ## TypeScript 配置改进 69 | - 启用了更严格的类型检查 70 | - 使用现代模块解析策略 71 | - 改进了源码映射配置 72 | - 优化了编译目标和库设置 73 | 74 | ## 构建系统改进 75 | - 添加了自动类型定义生成 76 | - 改进了 Rollup 配置以支持多种输出格式 77 | - 优化了开发和生产构建流程 78 | 79 | 所有更新都经过测试,确保向后兼容性。项目现在使用最新的工具链,提供更好的开发体验和更高的安全性。 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "maxrects-packer", 3 | "version": "2.7.4", 4 | "description": "A max rectangle 2d bin packer for packing glyphs or images into multiple sprite-sheet/atlas", 5 | "type": "module", 6 | "main": "dist/maxrects-packer.js", 7 | "module": "dist/maxrects-packer.mjs", 8 | "types": "dist/maxrects-packer.d.ts", 9 | "scripts": { 10 | "clean": "rimraf ./lib && rimraf ./dist", 11 | "build": "rollup -c", 12 | "build:clean": "npm run clean && npm run build", 13 | "doc": "typedoc && touch docs/.nojekyll && cp assets/favicon.ico docs/ && cp assets/favicon.png docs/assets/", 14 | "doc:clean": "rimraf docs && mkdir docs", 15 | "doc:json": "typedoc --json docs/typedoc.json", 16 | "doc:publish": "gh-pages --nojekyll -m \"[ci skip] Updates\" -d docs", 17 | "doc:serve": "cd docs && npx http-server", 18 | "test": "npm run build:clean && jest", 19 | "cover": "npm run build:clean && jest --coverage", 20 | "lint": "eslint src/**/*.ts", 21 | "lint:fix": "eslint src/**/*.ts --fix", 22 | "version": "standard-version", 23 | "prepare-release": "npm run test && npm run version" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://soimy@github.com/soimy/maxrects-packer.git" 28 | }, 29 | "keywords": [ 30 | "spritesheet", 31 | "atlas", 32 | "bin", 33 | "pack", 34 | "max", 35 | "rect" 36 | ], 37 | "author": "YM Shen (http://github.com/soimy)", 38 | "license": "MIT", 39 | "bugs": { 40 | "url": "https://github.com/soimy/maxrects-packer/issues" 41 | }, 42 | "homepage": "https://github.com/soimy/maxrects-packer#readme", 43 | "devDependencies": { 44 | "@rollup/plugin-terser": "^0.4.4", 45 | "@types/jest": "^29.5.12", 46 | "@types/node": "^22.18.0", 47 | "@typescript-eslint/eslint-plugin": "^8.8.1", 48 | "@typescript-eslint/parser": "^8.8.1", 49 | "ascii-table": "^0.0.9", 50 | "c8": "^10.1.2", 51 | "cz-conventional-changelog": "^3.3.0", 52 | "esbuild": "^0.24.0", 53 | "eslint": "^9.12.0", 54 | "eslint-plugin-import": "^2.31.0", 55 | "eslint-plugin-jsdoc": "^50.3.1", 56 | "gh-pages": "^6.3.0", 57 | "jest": "^29.7.0", 58 | "rimraf": "^6.0.1", 59 | "rollup": "^4.24.0", 60 | "rollup-plugin-dts": "^6.1.1", 61 | "rollup-plugin-esbuild": "^6.1.1", 62 | "rollup-plugin-typescript2": "^0.36.0", 63 | "standard-version": "^9.5.0", 64 | "ts-jest": "^29.2.5", 65 | "ts-node": "^10.9.2", 66 | "tslib": "^2.7.0", 67 | "typedoc": "^0.28.11", 68 | "typedoc-unhoax-theme": "^0.5.3", 69 | "typescript": "^5.6.3" 70 | }, 71 | "config": { 72 | "commitizen": { 73 | "path": "cz-conventional-changelog" 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | release: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Setup Node.js 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: '18' 21 | cache: 'npm' 22 | 23 | - name: Install dependencies 24 | run: npm ci 25 | 26 | - name: Build project 27 | run: npm run build:clean 28 | 29 | - name: Get tag version 30 | id: get_version 31 | run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT 32 | 33 | - name: Extract changelog 34 | id: extract_changelog 35 | run: | 36 | # Extract changelog for the current version 37 | VERSION="${{ steps.get_version.outputs.version }}" 38 | # Find the line number of the current version 39 | VERSION_LINE=$(grep -n "### \[$VERSION\]" CHANGELOG.md | head -1 | cut -d: -f1) 40 | if [ -n "$VERSION_LINE" ]; then 41 | # Find the next version line 42 | NEXT_VERSION_LINE=$(tail -n +$((VERSION_LINE + 1)) CHANGELOG.md | grep -n "^### \[" | head -1 | cut -d: -f1) 43 | if [ -n "$NEXT_VERSION_LINE" ]; then 44 | # Extract lines between current version and next version 45 | CHANGELOG=$(sed -n "$((VERSION_LINE + 1)),$((VERSION_LINE + NEXT_VERSION_LINE - 1))p" CHANGELOG.md) 46 | else 47 | # This is the last version, extract to end of file 48 | CHANGELOG=$(sed -n "$((VERSION_LINE + 1)),\$p" CHANGELOG.md) 49 | fi 50 | else 51 | CHANGELOG="No changelog found for version $VERSION" 52 | fi 53 | # Escape newlines for GitHub output 54 | CHANGELOG="${CHANGELOG//'%'/'%25'}" 55 | CHANGELOG="${CHANGELOG//$'\n'/'%0A'}" 56 | CHANGELOG="${CHANGELOG//$'\r'/'%0D'}" 57 | echo "changelog<> $GITHUB_OUTPUT 58 | echo "$CHANGELOG" >> $GITHUB_OUTPUT 59 | echo "EOF" >> $GITHUB_OUTPUT 60 | 61 | - name: Create release 62 | id: create_release 63 | run: | 64 | TAG_NAME="${{ github.ref_name }}" 65 | gh release create "$TAG_NAME" \ 66 | --title "Release $TAG_NAME" \ 67 | --notes "${{ steps.extract_changelog.outputs.changelog }}" \ 68 | --generate-notes 69 | env: 70 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 71 | 72 | - name: Upload release assets 73 | run: | 74 | TAG_NAME="${{ github.ref_name }}" 75 | gh release upload "$TAG_NAME" ./dist/maxrects-packer.js ./dist/maxrects-packer.min.js ./dist/maxrects-packer.mjs 76 | env: 77 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 78 | 79 | - name: Upload dist directory as zip 80 | run: | 81 | cd dist 82 | zip -r ../dist.zip . 83 | cd .. 84 | TAG_NAME="${{ github.ref_name }}" 85 | gh release upload "$TAG_NAME" ./dist.zip 86 | env: 87 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 88 | -------------------------------------------------------------------------------- /test/rectangle.spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const Rectangle = require("../src/geom/Rectangle").Rectangle; 4 | 5 | const bigRect = new Rectangle(512, 512, 0, 0); 6 | const containedRect = new Rectangle(256, 256, 16, 128); 7 | const collideRect = new Rectangle(256, 256, 384, 128); 8 | 9 | describe("Rectangle", () => { 10 | test("Default value", () => { 11 | const rect = new Rectangle(); 12 | expect(rect.width).toBe(0); 13 | expect(rect.height).toBe(0); 14 | expect(rect.x).toBe(0); 15 | expect(rect.y).toBe(0); 16 | expect(rect.dirty).toBe(false); 17 | }); 18 | 19 | test("Specified value", () => { 20 | const rect = new Rectangle(512, 512, 16); 21 | expect(rect.width).toBe(512); 22 | expect(rect.height).toBe(512); 23 | expect(rect.x).toBe(16); 24 | expect(rect.y).toBe(0); 25 | expect(rect.dirty).toBe(false); 26 | }); 27 | 28 | test("Dynamiclly changing value", () => { 29 | const rect = new Rectangle(512, 512, 16); 30 | rect.width = 256; 31 | rect.y = 32; 32 | expect(rect.width).toBe(256); 33 | expect(rect.y).toBe(32); 34 | expect(rect.dirty).toBe(true); 35 | }); 36 | 37 | test("Report dirty status correctly", () => { 38 | const rect = new Rectangle(512, 512, 16); 39 | rect.width = 256; 40 | expect(rect.dirty).toBe(true); 41 | rect.setDirty(false); 42 | expect(rect.dirty).toBe(false); 43 | rect.y = 32; 44 | expect(rect.dirty).toBe(true); 45 | rect.setDirty(false); 46 | rect.data = {foo: "bar"}; 47 | expect(rect.dirty).toBe(true); 48 | }); 49 | 50 | test("Rot flag functionality", () => { 51 | const rect = new Rectangle(512, 256); 52 | expect(rect.rot).toBe(false); 53 | rect.rot = true; 54 | expect(rect.rot).toBe(true); 55 | expect(rect.width).toBe(256); 56 | expect(rect.height).toBe(512); 57 | expect(rect.dirty).toBe(true); 58 | rect.setDirty(false); 59 | rect.rot = true; 60 | expect(rect.width).toBe(256); 61 | expect(rect.height).toBe(512); 62 | expect(rect.dirty).toBe(false); 63 | rect.rot = false; 64 | expect(rect.rot).toBe(false); 65 | expect(rect.width).toBe(512); 66 | expect(rect.height).toBe(256); 67 | expect(rect.dirty).toBe(true); 68 | }); 69 | 70 | test("allowRotation setting", () => { 71 | const rect = new Rectangle(512, 256, 0, 0, false, true); 72 | expect(rect.allowRotation).toBe(true); 73 | rect.allowRotation = false; 74 | expect(rect.allowRotation).toBe(false); 75 | }); 76 | 77 | test("data.allowRotation sync", () => { 78 | const rect = new Rectangle(512, 256); 79 | expect(rect.allowRotation).toBeUndefined(); 80 | rect.data = { allowRotation: false }; 81 | expect(rect.allowRotation).toBe(false); 82 | rect.data = { allowRotation: true }; 83 | expect(rect.allowRotation).toBe(true); 84 | }); 85 | 86 | test("method: area()", () => { 87 | const rect = new Rectangle(16, 16); 88 | expect(rect.area()).toBe(256); 89 | }); 90 | 91 | test("method: collide()", () => { 92 | expect(bigRect.collide(collideRect)).toBe(true); 93 | expect(bigRect.collide(containedRect)).toBe(true); 94 | expect(containedRect.collide(collideRect)).toBe(false); 95 | expect(Rectangle.Collide(bigRect, collideRect)).toBe(true); 96 | }); 97 | 98 | test("method: contain()", () => { 99 | expect(bigRect.contain(collideRect)).toBe(false); 100 | expect(bigRect.contain(containedRect)).toBe(true); 101 | expect(containedRect.contain(collideRect)).toBe(false); 102 | expect(Rectangle.Contain(bigRect, containedRect)).toBe(true); 103 | expect(Rectangle.Contain(bigRect, collideRect)).toBe(false); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "esnext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */ 5 | "module": "esnext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 6 | "esModuleInterop": true, 7 | "lib": ["esnext"], /* Specify library files to be included in the compilation: */ 8 | // "allowJs": true, /* Allow javascript files to be compiled. */ 9 | // "checkJs": true, /* Report errors in .js files. */ 10 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 11 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 12 | "declarationDir": "./lib/types", 13 | "sourceMap": true, /* Generates corresponding '.map' file. */ 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | "outDir": "./lib", /* Redirect output structure to the directory. */ 16 | "typeRoots": [ 17 | "lib" 18 | ], 19 | "baseUrl": ".", 20 | "paths": { 21 | "*": [ 22 | "node_modules/*", 23 | "lib/*" 24 | ] 25 | }, 26 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 27 | // "removeComments": true, /* Do not emit comments to output. */ 28 | // "noEmit": true, /* Do not emit outputs. */ 29 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 30 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 31 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 32 | 33 | /* Strict Type-Checking Options */ 34 | "strict": true, /* Enable all strict type-checking options. */ 35 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 36 | // "strictNullChecks": true, /* Enable strict null checks. */ 37 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 38 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 39 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 40 | 41 | /* Additional Checks */ 42 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 43 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 44 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 45 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 46 | 47 | /* Module Resolution Options */ 48 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 49 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 50 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 51 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 52 | // "typeRoots": [], /* List of folders to include type definitions from. */ 53 | "types": ["node"] /* Type declaration files to be included in compilation. */ 54 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 55 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 56 | 57 | /* Source Map Options */ 58 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 59 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 61 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 62 | 63 | /* Experimental Options */ 64 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 65 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 66 | }, 67 | "include": [ 68 | "./src/**/*", 69 | "./test/**/*", 70 | "./*.js", 71 | ] 72 | } -------------------------------------------------------------------------------- /src/geom/Rectangle.ts: -------------------------------------------------------------------------------- 1 | export interface IRectangle { 2 | width: number 3 | height: number 4 | x: number 5 | y: number 6 | [propName: string]: any 7 | } 8 | 9 | export class Rectangle implements IRectangle { 10 | /** 11 | * Oversized tag on rectangle which is bigger than packer itself. 12 | */ 13 | public oversized: boolean = false; 14 | 15 | /** 16 | * Creates an instance of Rectangle. 17 | * 18 | * @param width - width of the rectangle (default is 0) 19 | * @param height - height of the rectangle (default is 0) 20 | * @param x - x position of the rectangle (default is 0) 21 | * @param y - y position of the rectangle (default is 0) 22 | * @param rot - rotation flag (default is false) 23 | * @param allowRotation - allow rotation flag (default is undefined) 24 | */ 25 | constructor ( 26 | width: number = 0, 27 | height: number = 0, 28 | x: number = 0, 29 | y: number = 0, 30 | rot: boolean = false, 31 | allowRotation: boolean | undefined = undefined 32 | ) { 33 | this._width = width; 34 | this._height = height; 35 | this._x = x; 36 | this._y = y; 37 | this._data = {}; 38 | this._rot = rot; 39 | this._allowRotation = allowRotation; 40 | } 41 | 42 | /** 43 | * Test if two given rectangle collide each other 44 | * 45 | * @param first - first rectangle 46 | * @param second - second rectangle 47 | * @returns true if rectangles collide 48 | */ 49 | public static Collide (first: IRectangle, second: IRectangle) { return first.collide(second); } 50 | 51 | /** 52 | * Test if the first rectangle contains the second one 53 | * 54 | * @param first - first rectangle 55 | * @param second - second rectangle 56 | * @returns true if first rectangle contains the second 57 | */ 58 | public static Contain (first: IRectangle, second: IRectangle) { return first.contain(second); } 59 | 60 | /** 61 | * Get the area (w * h) of the rectangle 62 | * 63 | * @returns The area of the rectangle 64 | */ 65 | public area (): number { return this.width * this.height; } 66 | 67 | /** 68 | * Test if the given rectangle collide with this rectangle. 69 | * 70 | * @param rect - rectangle to test collision with 71 | * @returns true if rectangles collide 72 | */ 73 | public collide (rect: IRectangle): boolean { 74 | return ( 75 | rect.x < this.x + this.width && 76 | rect.x + rect.width > this.x && 77 | rect.y < this.y + this.height && 78 | rect.y + rect.height > this.y 79 | ); 80 | } 81 | 82 | /** 83 | * Test if this rectangle contains the given rectangle. 84 | * 85 | * @param rect - rectangle to test containment 86 | * @returns true if this rectangle contains the given rectangle 87 | */ 88 | public contain (rect: IRectangle): boolean { 89 | return (rect.x >= this.x && rect.y >= this.y && 90 | rect.x + rect.width <= this.x + this.width && rect.y + rect.height <= this.y + this.height); 91 | } 92 | 93 | protected _width: number; 94 | get width (): number { return this._width; } 95 | set width (value: number) { 96 | if (value === this._width) return; 97 | this._width = value; 98 | this._dirty ++; 99 | } 100 | 101 | protected _height: number; 102 | get height (): number { return this._height; } 103 | set height (value: number) { 104 | if (value === this._height) return; 105 | this._height = value; 106 | this._dirty ++; 107 | } 108 | 109 | protected _x: number; 110 | get x (): number { return this._x; } 111 | set x (value: number) { 112 | if (value === this._x) return; 113 | this._x = value; 114 | this._dirty ++; 115 | } 116 | 117 | protected _y: number; 118 | get y (): number { return this._y; } 119 | set y (value: number) { 120 | if (value === this._y) return; 121 | this._y = value; 122 | this._dirty ++; 123 | } 124 | 125 | protected _rot: boolean = false; 126 | 127 | /** 128 | * If the rectangle is rotated 129 | */ 130 | get rot (): boolean { return this._rot; } 131 | 132 | /** 133 | * Set the rotate tag of the rectangle. 134 | * 135 | * note: after `rot` is set, `width/height` of this rectangle is swaped. 136 | */ 137 | set rot (value: boolean) { 138 | if (this._allowRotation === false) return; 139 | 140 | if (this._rot !== value) { 141 | const tmp = this.width; 142 | this.width = this.height; 143 | this.height = tmp; 144 | this._rot = value; 145 | this._dirty ++; 146 | } 147 | } 148 | 149 | protected _allowRotation: boolean | undefined = undefined; 150 | 151 | /** 152 | * If the rectangle allow rotation 153 | */ 154 | get allowRotation (): boolean | undefined { return this._allowRotation; } 155 | 156 | /** 157 | * Set the allowRotation tag of the rectangle. 158 | */ 159 | set allowRotation (value: boolean | undefined) { 160 | if (this._allowRotation !== value) { 161 | this._allowRotation = value; 162 | this._dirty ++; 163 | } 164 | } 165 | 166 | protected _data: any; 167 | get data (): any { return this._data; } 168 | set data (value: any) { 169 | if (value === null || value === this._data) return; 170 | this._data = value; 171 | // extract allowRotation settings 172 | if (typeof value === "object" && value.hasOwnProperty("allowRotation")) { 173 | this._allowRotation = value.allowRotation; 174 | } 175 | this._dirty ++; 176 | } 177 | 178 | protected _dirty: number = 0; 179 | get dirty (): boolean { return this._dirty > 0; } 180 | public setDirty (value: boolean = true): void { this._dirty = value ? this._dirty + 1 : 0; } 181 | } 182 | -------------------------------------------------------------------------------- /test/efficiency.spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | let MaxRectsPacker = require("../src/maxrects-packer").MaxRectsPacker; 4 | let PACKING_LOGIC = require("../src/maxrects-packer").PACKING_LOGIC; 5 | let AsciiTable = require("ascii-table"); 6 | 7 | const SCENARIOS = require("./scenarios.json"); 8 | 9 | let rectSizeSum = SCENARIOS.map(scenario => { 10 | return scenario.reduce((memo, rect) => memo + rect.width * rect.height, 0); 11 | }); 12 | 13 | describe('Efficiency', () => { 14 | const AREA_CANDIDATES = [ 15 | {name: "1024x2048:0", factory: () => new MaxRectsPacker(1024, 2048, 0, { smart: true, pot: true, square: false, logic: PACKING_LOGIC.MAX_AREA })}, 16 | {name: "1024x2048:1", factory: () => new MaxRectsPacker(1024, 2048, 1, { smart: true, pot: true, square: false, logic: PACKING_LOGIC.MAX_AREA })}, 17 | {name: "1024x2048:1:Rot", factory: () => new MaxRectsPacker(1024, 2048, 1, { smart: true, pot: true, square: true, allowRotation: true, logic: PACKING_LOGIC.MAX_AREA })}, 18 | {name: "1024x1024:0", factory: () => new MaxRectsPacker(1024, 1024, 0, { smart: true, pot: true, square: false, logic: PACKING_LOGIC.MAX_AREA })}, 19 | {name: "1024x1024:1", factory: () => new MaxRectsPacker(1024, 1024, 1, { smart: true, pot: true, square: false, logic: PACKING_LOGIC.MAX_AREA })}, 20 | {name: "1024x1024:1:Rot", factory: () => new MaxRectsPacker(1024, 1024, 1, { smart: true, pot: true, square: true, allowRotation: true, logic: PACKING_LOGIC.MAX_AREA })}, 21 | {name: "2048:2048:1", factory: () => new MaxRectsPacker(2048, 2048, 1, { smart: true, pot: true, square: false, logic: PACKING_LOGIC.MAX_AREA })}, 22 | {name: "2048:2048:1:Rot", factory: () => new MaxRectsPacker(2048, 2048, 1, { smart: true, pot: true, square: true, allowRotation: true, logic: PACKING_LOGIC.MAX_AREA })} 23 | ]; 24 | 25 | const EDGE_CANDIDATES = [ 26 | {name: "1024x2048:0", factory: () => new MaxRectsPacker(1024, 2048, 0, { smart: true, pot: true, square: false, logic: PACKING_LOGIC.MAX_EDGE })}, 27 | {name: "1024x2048:1", factory: () => new MaxRectsPacker(1024, 2048, 1, { smart: true, pot: true, square: false, logic: PACKING_LOGIC.MAX_EDGE })}, 28 | {name: "1024x2048:1:Rot", factory: () => new MaxRectsPacker(1024, 2048, 1, { smart: true, pot: true, square: true, allowRotation: true, logic: PACKING_LOGIC.MAX_EDGE })}, 29 | {name: "1024x1024:0", factory: () => new MaxRectsPacker(1024, 1024, 0, { smart: true, pot: true, square: false, logic: PACKING_LOGIC.MAX_EDGE })}, 30 | {name: "1024x1024:1", factory: () => new MaxRectsPacker(1024, 1024, 1, { smart: true, pot: true, square: false, logic: PACKING_LOGIC.MAX_EDGE })}, 31 | {name: "1024x1024:1:Rot", factory: () => new MaxRectsPacker(1024, 1024, 1, { smart: true, pot: true, square: true, allowRotation: true, logic: PACKING_LOGIC.MAX_EDGE })}, 32 | {name: "2048:2048:1", factory: () => new MaxRectsPacker(2048, 2048, 1, { smart: true, pot: true, square: false, logic: PACKING_LOGIC.MAX_EDGE })}, 33 | {name: "2048:2048:1:Rot", factory: () => new MaxRectsPacker(2048, 2048, 1, { smart: true, pot: true, square: true, allowRotation: true, logic: PACKING_LOGIC.MAX_EDGE })} 34 | ]; 35 | 36 | test.skip('area logic', () => { 37 | let heading = ["#", "size"].concat(AREA_CANDIDATES.map(c => c.name)); 38 | let results = AREA_CANDIDATES.map(candidate => meassureEfficiency(candidate.factory)); 39 | let rows = createRows(results); 40 | 41 | console.log(new AsciiTable({ heading, rows }).toString()); 42 | }); 43 | 44 | test.skip('edge logic', () => { 45 | let heading = ["#", "size"].concat(EDGE_CANDIDATES.map(c => c.name)); 46 | let results = EDGE_CANDIDATES.map(candidate => meassureEfficiency(candidate.factory)); 47 | let rows = createRows(results); 48 | 49 | console.log(new AsciiTable({ heading, rows }).toString()); 50 | }); 51 | 52 | test('combined best of', () => { 53 | let heading = ["#", "size"].concat(AREA_CANDIDATES.map(c => c.name)); 54 | let results1 = EDGE_CANDIDATES.map(candidate => meassureEfficiency(candidate.factory)); 55 | let results2 = AREA_CANDIDATES.map(candidate => meassureEfficiency(candidate.factory)); 56 | let results = results1.map((scenario, scenarioIndex) => scenario.map((result1, resultIndex) => { 57 | const result2 = results2[scenarioIndex][resultIndex]; 58 | if (result1.bins < result2.bins) { 59 | result1.method = "E"; 60 | return result1; 61 | } else if (result1.bins > result2.bins) { 62 | result2.method = "A"; 63 | return result2; 64 | } else if (result1.efficieny > result2.efficieny) { 65 | result1.method = "E"; 66 | return result1; 67 | } else if (result1.efficieny < result2.efficieny) { 68 | result2.method = "A"; 69 | return result2; 70 | } else { 71 | result1.method = ""; 72 | return result1; 73 | } 74 | })); 75 | let rows = createRows(results); 76 | 77 | console.log(new AsciiTable({ heading, rows }).toString()); 78 | }); 79 | }); 80 | 81 | function meassureEfficiency (factory) { 82 | return SCENARIOS.map((scenario, i) => { 83 | let packer = factory(); 84 | packer.addArray(scenario); 85 | 86 | let bins = packer.bins.length; 87 | let rectSize = rectSizeSum[i]; 88 | let usedSize = packer.bins.reduce((memo, bin) => memo + bin.width * bin.height, 0); 89 | let efficieny = rectSize / usedSize; 90 | return {bins, rectSize, usedSize, efficieny}; 91 | }); 92 | } 93 | 94 | function toPercent (input) { 95 | return Math.round(input * 1000) / 10 + "%"; 96 | } 97 | 98 | function createRows (results) { 99 | return SCENARIOS.map((scenario, i) => { 100 | return [i, rectSizeSum[i]].concat(results.map(resultCandidate => { 101 | let result = resultCandidate[i]; 102 | return `${toPercent(result.efficieny)} (${result.bins} bins)${result.method}`; 103 | })); 104 | }).concat([["sum", ""].concat(results.map(result => { 105 | let usedSize = result.reduce((memo, data) => memo + data.usedSize, 0); 106 | let rectSize = result.reduce((memo, data) => memo + data.rectSize, 0); 107 | let totalBins = result.reduce((memo, data) => memo + data.bins, 0); 108 | return `${toPercent(rectSize / usedSize)} (${totalBins} bins)`; 109 | }))]); 110 | } 111 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [2.7.4](https://github.com/soimy/maxrects-packer/compare/v2.7.3...v2.7.4) (2025-08-28) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * circular dependencies on maxracts-packer.ts ([1172feb](https://github.com/soimy/maxrects-packer/commit/1172feb0c702a6d8e7416aab3f0ef92d359d4066)) 11 | * Bugs in edgy rect placement in updateBinSize() (#56) 12 | * 51 expanding one dimension (#54) 13 | 14 | ### [2.7.3](https://github.com/soimy/maxrects-packer/compare/v2.7.2...v2.7.3) (2022-02-02) 15 | 16 | 17 | ### Bug Fixes 18 | 19 | * Recursion with non-exclusive tags addArray ([#34](https://github.com/soimy/maxrects-packer/issues/34)) ([d107163](https://github.com/soimy/maxrects-packer/commit/d107163dc214e1f4f45d1bf4241efe8e5b1b34b3)) 20 | 21 | ### [2.7.2](https://github.com/soimy/maxrects-packer/compare/v2.7.1...v2.7.2) (2021-01-15) 22 | 23 | 24 | ### Bug Fixes 25 | 26 | * addArray empty array throw error [#23](https://github.com/soimy/maxrects-packer/issues/23) ([9ba6605](https://github.com/soimy/maxrects-packer/commit/9ba6605)) 27 | 28 | 29 | 30 | ### [2.7.1](https://github.com/soimy/maxrects-packer/compare/v2.7.0...v2.7.1) (2020-09-18) 31 | 32 | 33 | ### Bug Fixes 34 | 35 | * Non-exclusive tag logic: Restart test iteration on next tag group ([8b59925](https://github.com/soimy/maxrects-packer/commit/8b59925)), closes [#20](https://github.com/soimy/maxrects-packer/issues/20) [#20](https://github.com/soimy/maxrects-packer/issues/20) 36 | 37 | 38 | 39 | ## [2.7.0](https://github.com/soimy/maxrects-packer/compare/v2.6.0...v2.7.0) (2020-09-16) 40 | 41 | 42 | ### Features 43 | 44 | * Non exclusive tag grouping ([#21](https://github.com/soimy/maxrects-packer/issues/21)) ([cf28fc5](https://github.com/soimy/maxrects-packer/commit/cf28fc5)), closes [#20](https://github.com/soimy/maxrects-packer/issues/20) 45 | 46 | 47 | 48 | ## [2.6.0](https://github.com/soimy/maxrects-packer/compare/v2.5.0...v2.6.0) (2020-02-13) 49 | 50 | 51 | ### Bug Fixes 52 | 53 | * fixes to default packing logic usage and documentation ([#12](https://github.com/soimy/maxrects-packer/issues/12)) ([caa1532](https://github.com/soimy/maxrects-packer/commit/caa1532)) 54 | 55 | 56 | ### Build System 57 | 58 | * Update rollup config & dts plugin ([1d1b80e](https://github.com/soimy/maxrects-packer/commit/1d1b80e)) 59 | 60 | 61 | ### Features 62 | 63 | * Per rectangle allowRotation ([ab68e1c](https://github.com/soimy/maxrects-packer/commit/ab68e1c)) 64 | 65 | 66 | 67 | ## [2.5.0](https://github.com/soimy/maxrects-packer/compare/v2.4.5...v2.5.0) (2019-09-03) 68 | 69 | 70 | ### Features 71 | 72 | * area or edge logic selection option ([c4dff4b](https://github.com/soimy/maxrects-packer/commit/c4dff4b)) 73 | 74 | 75 | ### Tests 76 | 77 | * efficiency tests refactoring ([0afa958](https://github.com/soimy/maxrects-packer/commit/0afa958)) 78 | 79 | 80 | 81 | ### [2.4.5](https://github.com/soimy/maxrects-packer/compare/v2.4.4...v2.4.5) (2019-08-26) 82 | 83 | 84 | 85 | ### [2.4.4](https://github.com/soimy/maxrects-packer/compare/v2.4.3...v2.4.4) (2019-07-10) 86 | 87 | 88 | ### Bug Fixes 89 | 90 | * save/load bins with tag correctly ([ac95c4a](https://github.com/soimy/maxrects-packer/commit/ac95c4a)) 91 | 92 | 93 | 94 | ### [2.4.3](https://github.com/soimy/maxrects-packer/compare/v2.4.2...v2.4.3) (2019-07-09) 95 | 96 | 97 | ### Bug Fixes 98 | 99 | * process() vertical expand not take border into account ([7382912](https://github.com/soimy/maxrects-packer/commit/7382912)) 100 | * updateBinSize() not consider rotated node ([aee9a4b](https://github.com/soimy/maxrects-packer/commit/aee9a4b)) 101 | 102 | 103 | ### Tests 104 | 105 | * add set rotation test ([0f1d301](https://github.com/soimy/maxrects-packer/commit/0f1d301)) 106 | 107 | 108 | 109 | ### [2.4.2](https://github.com/soimy/maxrects-packer/compare/v2.4.1...v2.4.2) (2019-07-08) 110 | 111 | 112 | ### Bug Fixes 113 | 114 | * handle rotated rectangle feeded to packer correctly ([81c80e0](https://github.com/soimy/maxrects-packer/commit/81c80e0)) 115 | 116 | 117 | 118 | ### [2.4.1](https://github.com/soimy/maxrects-packer/compare/v2.4.0...v2.4.1) (2019-07-04) 119 | 120 | 121 | 122 | ## [2.4.0](https://github.com/soimy/maxrects-packer/compare/v2.4.0-alpha.0...v2.4.0) (2019-06-30) 123 | 124 | 125 | ### Bug Fixes 126 | 127 | * Export IBin interface ([aaa40a4](https://github.com/soimy/maxrects-packer/commit/aaa40a4)) 128 | 129 | 130 | 131 | ## [2.4.0-alpha.0](https://github.com/soimy/maxrects-packer/compare/v2.3.0...v2.4.0-alpha.0) (2019-06-19) 132 | 133 | 134 | ### Bug Fixes 135 | 136 | * **maxrects-bin.ts:** split freerect not use rotated node ([5f06524](https://github.com/soimy/maxrects-packer/commit/5f06524)) 137 | 138 | 139 | ### Features 140 | 141 | * Add `Bin.border` to control space to edge ([62bc66b](https://github.com/soimy/maxrects-packer/commit/62bc66b)), closes [#5](https://github.com/soimy/maxrects-packer/issues/5) 142 | * Implement `dirty` status get/set of `Rectangle`&`MaxRectsBin` ([ca932ba](https://github.com/soimy/maxrects-packer/commit/ca932ba)) 143 | * Report bin dirty status ([a7527b6](https://github.com/soimy/maxrects-packer/commit/a7527b6)) 144 | * reset/repack (beta) ([eb93239](https://github.com/soimy/maxrects-packer/commit/eb93239)) 145 | 146 | 147 | 148 | ## [2.3.0](https://github.com/soimy/maxrects-packer/compare/v2.2.0...v2.3.0) (2019-06-06) 149 | 150 | 151 | ### Features 152 | 153 | * tag based group packing ([#7](https://github.com/soimy/maxrects-packer/issues/7)) ([0fa7a8c](https://github.com/soimy/maxrects-packer/commit/0fa7a8c)) 154 | 155 | 156 | 157 | ## [2.2.0](https://github.com/soimy/maxrects-packer/compare/v2.1.2...v2.2.0) (2019-06-04) 158 | 159 | 160 | ### Features 161 | 162 | * Add `.next()` method to enclose and start a new bin ([9dbe754](https://github.com/soimy/maxrects-packer/commit/9dbe754)) 163 | 164 | 165 | 166 | ### [2.1.2](https://github.com/soimy/maxrects-packer/compare/v2.1.1...v2.1.2) (2019-06-03) 167 | 168 | 169 | 170 | ### [2.1.1](https://github.com/soimy/maxrects-packer/compare/v2.1.0...v2.1.1) (2019-06-03) 171 | 172 | 173 | ### Build System 174 | 175 | * Update package config & toolchain ([b117652](https://github.com/soimy/maxrects-packer/commit/b117652)) 176 | 177 | 178 | ### Features 179 | 180 | * Add hash as 2nd sort for stable pack queue ([499b82e](https://github.com/soimy/maxrects-packer/commit/499b82e)) 181 | * Retangle class rot&data getter setter ([438014e](https://github.com/soimy/maxrects-packer/commit/438014e)) 182 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | /* 2 | 👋 Hi! This file was autogenerated by tslint-to-eslint-config. 3 | https://github.com/typescript-eslint/tslint-to-eslint-config 4 | 5 | It represents the closest reasonable ESLint configuration to this 6 | project's original TSLint configuration. 7 | 8 | We recommend eventually switching this configuration to extend from 9 | the recommended rulesets in typescript-eslint. 10 | https://github.com/typescript-eslint/tslint-to-eslint-config/blob/master/docs/FAQs.md 11 | 12 | Happy linting! 💖 13 | */ 14 | { 15 | "env": { 16 | "es6": true, 17 | "node": true 18 | }, 19 | "parser": "@typescript-eslint/parser", 20 | "parserOptions": { 21 | "project": "./tsconfig.json", 22 | "sourceType": "module" 23 | }, 24 | "plugins": [ 25 | "eslint-plugin-import", 26 | "eslint-plugin-jsdoc", 27 | "@typescript-eslint", 28 | "@typescript-eslint/tslint" 29 | ], 30 | "root": true, 31 | "rules": { 32 | "@typescript-eslint/await-thenable": "error", 33 | "@typescript-eslint/consistent-type-assertions": "error", 34 | "@typescript-eslint/member-delimiter-style": [ 35 | "error", 36 | { 37 | "multiline": { 38 | "delimiter": "none", 39 | "requireLast": true 40 | }, 41 | "singleline": { 42 | "delimiter": "semi", 43 | "requireLast": false 44 | } 45 | } 46 | ], 47 | "@typescript-eslint/naming-convention": [ 48 | "error", 49 | { 50 | "selector": "variable", 51 | "format": [ 52 | "camelCase", 53 | "UPPER_CASE", 54 | "PascalCase" 55 | ], 56 | "leadingUnderscore": "allow", 57 | "trailingUnderscore": "forbid" 58 | } 59 | ], 60 | "@typescript-eslint/no-empty-function": "error", 61 | "@typescript-eslint/no-floating-promises": "error", 62 | "@typescript-eslint/no-misused-new": "error", 63 | "@typescript-eslint/no-unnecessary-qualifier": "error", 64 | "@typescript-eslint/no-unnecessary-type-assertion": "error", 65 | "@typescript-eslint/no-unused-expressions": [ 66 | "error", 67 | { 68 | "allowTaggedTemplates": true, 69 | "allowShortCircuit": true 70 | } 71 | ], 72 | "@typescript-eslint/prefer-namespace-keyword": "error", 73 | "@typescript-eslint/quotes": "off", 74 | "@typescript-eslint/semi": [ 75 | "error" 76 | ], 77 | "@typescript-eslint/triple-slash-reference": [ 78 | "error", 79 | { 80 | "path": "always", 81 | "types": "prefer-import", 82 | "lib": "always" 83 | } 84 | ], 85 | "@typescript-eslint/type-annotation-spacing": "error", 86 | "@typescript-eslint/unified-signatures": "error", 87 | "brace-style": [ 88 | "error", 89 | "1tbs", 90 | { "allowSingleLine": true } 91 | ], 92 | "comma-dangle": ["error", "only-multiline"], 93 | "curly": [ 94 | "error", 95 | "multi-line" 96 | ], 97 | "eol-last": "error", 98 | "eqeqeq": [ 99 | "error", 100 | "smart" 101 | ], 102 | "id-denylist": [ 103 | "error", 104 | "any", 105 | "Number", 106 | "number", 107 | "String", 108 | "string", 109 | "Boolean", 110 | "boolean", 111 | "Undefined", 112 | "undefined" 113 | ], 114 | "id-match": "error", 115 | "import/no-deprecated": "error", 116 | "jsdoc/check-alignment": "error", 117 | "jsdoc/check-indentation": "error", 118 | "jsdoc/newline-after-description": "error", 119 | "new-parens": "error", 120 | "no-caller": "error", 121 | "no-cond-assign": "error", 122 | "no-constant-condition": "error", 123 | "no-control-regex": "error", 124 | "no-duplicate-imports": "error", 125 | "no-empty": "error", 126 | "no-empty-function": "off", 127 | "no-eval": "error", 128 | "no-fallthrough": "error", 129 | "no-invalid-regexp": "error", 130 | "no-multiple-empty-lines": "error", 131 | "no-redeclare": "error", 132 | "no-regex-spaces": "error", 133 | "no-return-await": "error", 134 | "no-throw-literal": "error", 135 | "no-trailing-spaces": "error", 136 | "no-underscore-dangle": "off", 137 | "no-unused-expressions": "off", 138 | "no-unused-labels": "error", 139 | "no-var": "error", 140 | "one-var": [ 141 | "error", 142 | "never" 143 | ], 144 | "quotes": "off", 145 | "radix": "error", 146 | "semi": "off", 147 | "space-before-function-paren": [ 148 | "error", 149 | "always" 150 | ], 151 | "space-in-parens": [ 152 | "error", 153 | "never" 154 | ], 155 | "spaced-comment": [ 156 | "error", 157 | "always", 158 | { 159 | "markers": [ 160 | "/" 161 | ] 162 | } 163 | ], 164 | "use-isnan": "error", 165 | "@typescript-eslint/tslint/config": [ 166 | "error", 167 | { 168 | "rules": { 169 | "block-spacing": [ 170 | true, 171 | "always" 172 | ], 173 | "brace-style": [ 174 | true, 175 | "1tbs", 176 | { 177 | "allowSingleLine": true 178 | } 179 | ], 180 | "handle-callback-err": [ 181 | true, 182 | "^(err|error)$" 183 | ], 184 | "no-duplicate-case": true, 185 | "no-empty-character-class": true, 186 | "no-ex-assign": true, 187 | "no-extra-boolean-cast": true, 188 | "no-inner-declarations": [ 189 | true, 190 | "functions" 191 | ], 192 | "no-multi-spaces": true, 193 | "no-unexpected-multiline": true, 194 | "object-curly-spacing": [ 195 | true, 196 | "always" 197 | ], 198 | "strict-type-predicates": true, 199 | "ter-arrow-spacing": [ 200 | true, 201 | { 202 | "before": true, 203 | "after": true 204 | } 205 | ], 206 | "ter-func-call-spacing": [ 207 | true, 208 | "never" 209 | ], 210 | "ter-indent": true, 211 | "ter-no-irregular-whitespace": true, 212 | "ter-no-sparse-arrays": true, 213 | "valid-typeof": true, 214 | "whitespace": [ 215 | true, 216 | "check-branch", 217 | "check-decl", 218 | "check-operator", 219 | "check-rest-spread", 220 | "check-type", 221 | "check-typecast", 222 | "check-type-operator", 223 | "check-preblock" 224 | ] 225 | } 226 | } 227 | ] 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![icon](./assets/favicon32.png) Max Rects Packer 2 | 3 | [![Node.js CI](https://github.com/soimy/maxrects-packer/actions/workflows/node.js.yml/badge.svg)](https://github.com/soimy/maxrects-packer/actions/workflows/node.js.yml) 4 | [![codecov](https://codecov.io/gh/soimy/maxrects-packer/branch/master/graph/badge.svg)](https://codecov.io/gh/soimy/maxrects-packer) 5 | [![npm version](https://badge.fury.io/js/maxrects-packer.svg)](https://badge.fury.io/js/maxrects-packer) 6 | ![npm](https://img.shields.io/npm/dm/maxrects-packer.svg) 7 | ![NPM Type Definitions](https://img.shields.io/npm/types/maxrects-packer) 8 | 9 | A simple max rectangle 2d bin packing algorithm for packing glyphs or images into multiple sprite-sheet/atlas. Minimalist with no module dependency. 10 | 11 | This is a evolved version of [Multi-Bin-Packer](https://github.com/marekventur/multi-bin-packer) with much efficient packing algorithm. All interfaces and methods are inherited so no tweaks needed in your current code except module name. 12 | 13 | It differs from the long list of similar packages by its packing approach: Instead of creating one output bin with a minimum size this package is trying to create a minimum number of bins under a certain size. This avoids problems with single massive image files that are not browser-friendly. This can be especially useful for WebGL games where the GPU will benefit from spritesheets close to power-of-2 sizes. 14 | 15 | And you can also save/load to reuse packer to add new sprites. (Below is a demo atlas packed with two difference bitmap fonts) 16 | 17 | ![Preview image](./assets/preview.png) 18 | 19 | ## [Changelog](https://github.com/soimy/maxrects-packer/blob/master/CHANGELOG.md) 20 | 21 | ## Installing 22 | 23 | ```bash 24 | npm install maxrects-packer --save 25 | ``` 26 | 27 | ## Usage 28 | 29 | **Note:** *Since version 2.0.0 Max Rects Packer is rewritten in `typescript` and change the import method from old `require("maxrects-packer")` to `require("maxrects-packer").MaxRectsPacker` or more fashioned `import` statement.* 30 | 31 | **Note:** *Since version 2.1.0 packer can be fed with any object with `width & height` members, no need to follow `{ width: number, height: number, data: any }` pattern, if you are using `typescript`, that also mean any class extending `MaxRectsPacker.IRectangle`* 32 | 33 | **Note:** *Since version 2.1.0 Rectangle class constructor API is changed from `new Rectangle(x, y, width, height, rotated)` to `new Rectangle(width, height, x, y, rotated)`, cos most cases you only need to feed w/h and omit the rest like `new Rectangle(100, 100)` and left `x,y,rotated` to default value. 34 | 35 | ```javascript 36 | let MaxRectsPacker = require("maxrects-packer").MaxRectsPacker; 37 | const options = { 38 | smart: true, 39 | pot: true, 40 | square: false, 41 | allowRotation: true, 42 | tag: false, 43 | border: 5 44 | }; // Set packing options 45 | let packer = new MaxRectsPacker(1024, 1024, 2, options); // width, height, padding, options 46 | 47 | let input = [ // any object with width & height is OK since v2.1.0 48 | {width: 600, height: 20, name: "tree", foo: "bar"}, 49 | {width: 600, height: 20, name: "flower"}, 50 | {width: 2000, height: 2000, name: "oversized background", {frameWidth: 500, frameHeight: 500}}, 51 | {width: 1000, height: 1000, name: "background", color: 0x000000ff}, 52 | {width: 1000, height: 1000, name: "overlay", allowRotation: true} 53 | ]; 54 | 55 | packer.addArray(input); // Start packing with input array 56 | packer.next(); // Start a new packer bin 57 | packer.addArray(input.slice(2)); // Adding to the new bin 58 | packer.bins.forEach(bin => { 59 | console.log(bin.rects); 60 | }); 61 | 62 | // Reuse packer 63 | let bins = packer.save(); 64 | packer.load(bins); 65 | packer.addArray(input); 66 | 67 | ``` 68 | 69 | ## Test 70 | 71 | ```bash 72 | npm test 73 | ``` 74 | 75 | ## API 76 | 77 | Note: maxrects-packer requires node >= 4.0.0 78 | 79 | #### ```new MaxRectsPacker(maxWidth, maxHeight[, padding, options])``` 80 | 81 | Creates a new Packer. maxWidth and maxHeight are passed on to all bins. If ```padding``` is supplied all rects will be kept at least ```padding``` pixels apart. 82 | 83 | - `options.smart` packing with smallest possible size. (default is `true`) 84 | - `options.pot` bin size round up to smallest power of 2. (default is `true`) 85 | - `options.square` bin size shall alway be square. (default is `false`) 86 | - `options.allowRotation` allow 90-degree rotation while packing. (default is `false`) 87 | - `options.tag` allow tag based group packing. (default is `false`) 88 | - `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`) 89 | - `options.border` atlas edge spacing (default is 0) 90 | - `options.logic` how to fill the rects. There are three options: 0 (max area), 1 (max edge), 2 (fillWidth). Default is 1 (max edge) 91 | 92 | #### ```packer.add(width, height, data)``` +1 overload 93 | 94 | Adds a rect to an existing bin or creates a new one to accommodate it. ```data``` can be anything, it will be stored along with the position data of each rect. 95 | 96 | #### ```packer.add({width: number, height: number, ... })``` +1 overload 97 | 98 | Adds a rect to an existing bin or creates a new one to accommodate it. Accept any object with `width & height`. If you are using `typescript`, that means any class extends `MaxRectsPacker.IRectangle` 99 | 100 | #### ```packer.addArray([{width: number, height: number, ...}, ...])``` 101 | 102 | Adds multiple rects. Since the input is automatically sorted before adding this approach usually leads to fewer bins created than separate calls to ```.add()``` 103 | 104 | #### ```packer.repack(quick: boolean = true)``` 105 | 106 | Repack all elements inside bins. If `quick == true`, only bins with `dirty` flag will be repacked. If `false` is passed, all rects inside this packer will be re-sort and repacked, might result different bin number. Slower but high packing efficiency. 107 | 108 | #### ```packer.next()``` 109 | 110 | Stop adding new element to the current bin and return a new bin. After calling `next()` all elements will no longer added to previous bins. 111 | 112 | #### ```let bins = packer.save()``` 113 | 114 | Save current bins settings and free area to an Array of objects for later use. Better to `JSON.stringify(bins)` and store in file. 115 | 116 | #### ```packer.load(bins)``` 117 | 118 | Restore previous saved `let bins = JSON.parse(fs.readFileSync(savedFile, 'utf8'));` settings and overwrite current one. Continue packing and previous packed area will not be overlapped. 119 | 120 | #### ```packer.bins``` 121 | 122 | Array of bins. Every bin has a ```width``` and ```height``` parameter as well as an array ```rects```. 123 | 124 | #### ```packer.bins[n].rects``` 125 | 126 | Array of rects for a specific bin. Every rect has ```x```, ```y```, ```width```, ```height```, ```rot``` and ```data```. In case of an rect exceeding ```maxWidth```/```maxHeight``` there will also be an ```oversized``` flag set to ```true```. 127 | 128 | ## Support for 90-degree rotation packing 129 | 130 | If `options.allowRotation` is set to `true`, packer will attempt to do an extra test in `findNode()` on rotated `Rectangle`. If the rotated one gives the best score, the given `Rectangle` will be rotated in the `Rectangle.rot` set to `true`. 131 | 132 | ## Support for tag based group packing 133 | 134 | If `options.tag` is set to `true`, packer will check if the input object has `tag: string` property, all input with same `tag` will be packed in the same bin. 135 | 136 | ## Support for oversized rectangles 137 | 138 | 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. 139 | 140 | ## Packing logic 141 | 142 | `options.logic` allows to change the method on how the algorithm selects the free spaces. There are three options: 143 | 144 | `{option.logic = 0}` Logic is MAX_AREA, selects the free space with the smallest loss of area. 145 | 146 | `{option.logic = 1}` Logic is MAX_EDGE, is default and selects the free space with the smallest loss of either width or height. 147 | 148 | `{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` 149 | 150 | 151 | 152 | ## Packing algorithm 153 | 154 | Use Max Rectangle Algorithm for packing, same as famous **Texture Packer** 155 | -------------------------------------------------------------------------------- /test/maxrects-packer.spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | let MaxRectsPacker = require("../src/maxrects-packer").MaxRectsPacker; 4 | let PACKING_LOGIC = require("../src/maxrects-packer").PACKING_LOGIC; 5 | let Rectangle = require("../src/geom/Rectangle").Rectangle; 6 | 7 | const opt = { 8 | smart: true, 9 | pot: false, 10 | square: false, 11 | allowRotation: false, 12 | tag: false, 13 | exclusiveTag: true 14 | }; 15 | 16 | let packer; 17 | beforeEach(() => { 18 | packer = new MaxRectsPacker(1024, 1024, 0, opt); 19 | }); 20 | 21 | describe("#add", () => { 22 | test("adds first element correctly", () => { 23 | packer.add(1000, 1000, {num: 1}); 24 | expect(packer.bins[0].rects[0].data.num).toBe(1); 25 | }); 26 | 27 | test("creates additional bin if element doesn't fit in existing bin", () => { 28 | packer.add(1000, 1000, {num: 1}); 29 | packer.add(1000, 1000, {num: 2}); 30 | expect(packer.bins.length).toBe(2); 31 | expect(packer.bins[1].rects[0].data.num).toBe(2); 32 | }); 33 | 34 | test("adds to existing bins if possible", () => { 35 | packer.add(1000, 1000, {num: 1}); 36 | packer.add(1000, 1000, {num: 2}); 37 | packer.add(10, 10, {num: 3}); 38 | packer.add(10, 10, {num: 4}); 39 | expect(packer.bins.length).toBe(2); 40 | }); 41 | 42 | test("adds to new bins after next() is called", () => { 43 | packer.add(1000, 1000, {num: 1}); 44 | packer.add(1000, 1000, {num: 2}); 45 | packer.next(); 46 | packer.add(10, 10, {num: 3}); 47 | packer.add(10, 10, {num: 4}); 48 | expect(packer.bins.length).toBe(3); 49 | expect(packer.bins[packer.bins.length - 1].rects.length).toBe(2); 50 | }); 51 | 52 | test("adds to bins with tag matching on", () => { 53 | packer.options.tag = true; 54 | packer.add(1000, 1000, {num: 1}); 55 | packer.add(10, 10, {num: 2}); 56 | packer.add(1000, 1000, {num: 3, tag: "one"}); 57 | packer.add(1000, 1000, {num: 4, tag: "one"}); 58 | packer.add(10, 10, {num: 5, tag: "one"}); 59 | packer.add(10, 10, {num: 6, tag: "one"}); 60 | packer.add(10, 10, {num: 7, tag: "two"}); 61 | packer.next(); 62 | packer.add(10, 10, {num: 8, tag: "two"}); 63 | expect(packer.bins.length).toBe(5); 64 | expect(packer.bins[0].rects.length).toBe(2); 65 | expect(packer.bins[0].tag).toBeUndefined(); 66 | expect(packer.bins[1].rects.length).toBe(3); 67 | expect(packer.bins[1].tag).toBe("one"); 68 | expect(packer.bins[2].rects.length).toBe(1); 69 | expect(packer.bins[2].tag).toBe("one"); 70 | expect(packer.bins[packer.bins.length - 1].rects.length).toBe(1); 71 | expect(packer.bins[packer.bins.length - 1].tag).toBe("two"); 72 | }); 73 | 74 | test("adds to bins with tag matching disable", () => { 75 | packer.options.tag = false; 76 | packer.add(1000, 1000, {num: 1}); 77 | packer.add(10, 10, {num: 2}); 78 | packer.add(1000, 1000, {num: 3, tag: "one"}); 79 | packer.add(10, 10, {num: 4, tag: "two"}); 80 | packer.next(); 81 | packer.add(10, 10, {num: 8, tag: "two"}); 82 | expect(packer.bins.length).toBe(3); 83 | expect(packer.bins[0].tag).toBeUndefined(); 84 | expect(packer.bins[1].tag).toBeUndefined(); 85 | expect(packer.bins[packer.bins.length - 1].rects.length).toBe(1); 86 | expect(packer.bins[packer.bins.length - 1].tag).toBeUndefined(); 87 | }); 88 | 89 | test("adds to bins with non-exclusive tag matching", () => { 90 | packer.options = {...packer.options, ...{tag: true, exclusiveTag: false}}; 91 | let input = [ 92 | {width: 512, height: 512, data: {}}, 93 | {width: 512, height: 512, data: {tag: "one"}}, 94 | {width: 512, height: 512, data: {tag: "two"}}, 95 | {width: 512, height: 512, data: {tag: "two"}}, 96 | {width: 512, height: 512, data: {tag: "two"}}, 97 | // Will break into its own bin 98 | {width: 600, height: 600, data: {tag: "two"}}, 99 | {width: 512, height: 512, data: {tag: "two"}}, 100 | {width: 512, height: 512, data: {tag: "one"}}, 101 | {width: 512, height: 512, data: {}} 102 | ]; 103 | packer.addArray(input); 104 | expect(packer.bins.length).toBe(3); 105 | expect(packer.bins[0].tag).toBeUndefined(); 106 | expect(packer.bins[1].tag).toBeUndefined(); 107 | expect(packer.bins[2].tag).toBeUndefined(); 108 | expect(packer.bins[0].rects.length).toBe(4); 109 | expect(packer.bins[1].rects.length).toBe(4); 110 | expect(packer.bins[2].rects.length).toBe(1); 111 | expect(packer.bins[0].rects[0].data.tag).toBe("one"); 112 | expect(packer.bins[0].rects[1].data.tag).toBe("one"); 113 | expect(packer.bins[0].rects[2].data.tag).toBe("two"); 114 | expect(packer.bins[0].rects[3].data.tag).toBe("two"); 115 | expect(packer.bins[1].rects[0].data.tag).toBe("two"); 116 | expect(packer.bins[1].rects[1].data.tag).toBe("two"); 117 | expect(packer.bins[1].rects[2].data.tag).toBeUndefined(); 118 | expect(packer.bins[1].rects[3].data.tag).toBeUndefined(); 119 | expect(packer.bins[2].rects[0].data.tag).toBe("two"); 120 | }); 121 | 122 | test("allows oversized elements to be added", () => { 123 | packer.add(1000, 1000, {num: 1}); 124 | packer.add(2000, 2000, {num: 2}); 125 | expect(packer.bins.length).toBe(2); 126 | expect(packer.bins[1].rects[0].width).toBe(2000); 127 | expect(packer.bins[1].rects[0].oversized).toBe(true); 128 | }); 129 | 130 | test("checks oversized elements rotation and adds rotated", () => { 131 | const packer = new MaxRectsPacker(512, 1024, 0, {...opt, allowRotation: true}); 132 | packer.add(640, 256, {num: 1}); 133 | expect(packer.bins.length).toBe(1); 134 | expect(packer.bins[0].rects[0].width).toBe(256); 135 | expect(packer.bins[0].rects[0].height).toBe(640); 136 | expect(packer.bins[0].rects[0].oversized).toBe(false); 137 | }); 138 | 139 | test("checks oversized elements and skip rotation when set to false", () => { 140 | const packer = new MaxRectsPacker(512, 1024, 0, {...opt, allowRotation: false}); 141 | packer.add(640, 256, {num: 1}); 142 | expect(packer.bins.length).toBe(1); 143 | expect(packer.bins[0].rects[0].width).toBe(640); 144 | expect(packer.bins[0].rects[0].oversized).toBe(true); 145 | }); 146 | }); 147 | 148 | describe("#sort", () => { 149 | test("does not mutate input array", () => { 150 | let input = [ 151 | {width: 1, height: 1}, 152 | {width: 2, height: 2} 153 | ]; 154 | packer.sort(input); 155 | expect(input[0].width).toBe(1); 156 | }); 157 | 158 | test("works correctly by area", () => { 159 | let input = [ 160 | {width: 1, height: 1}, 161 | {width: 3, height: 1}, 162 | {width: 2, height: 2} 163 | ]; 164 | let output = packer.sort(input, PACKING_LOGIC.MAX_AREA); 165 | expect(output[0].width).toBe(2); 166 | expect(output[1].width).toBe(3); 167 | expect(output[2].width).toBe(1); 168 | }); 169 | 170 | test("works correctly by edge", () => { 171 | let input = [ 172 | {width: 1, height: 1}, 173 | {width: 3, height: 1}, 174 | {width: 2, height: 2} 175 | ]; 176 | let output = packer.sort(input, PACKING_LOGIC.MAX_EDGE); 177 | expect(output[0].width).toBe(3); 178 | expect(output[1].width).toBe(2); 179 | expect(output[2].width).toBe(1); 180 | }); 181 | }); 182 | 183 | describe("#addArray", () => { 184 | test("adds multiple elements to bins", () => { 185 | let input = [ 186 | {width: 1000, height: 1000, data: {num: 1}}, 187 | {width: 1000, height: 1000, data: {num: 2}} 188 | ]; 189 | packer.addArray(input); 190 | expect(packer.bins.length).toBe(2); 191 | }); 192 | 193 | test("adds the big rects first", () => { 194 | let input = [ 195 | {width: 600, height: 20, data: {num: 1}}, 196 | {width: 600, height: 20, data: {num: 2}}, 197 | {width: 1000, height: 1000, data: {num: 3}}, 198 | {width: 1000, height: 1000, data: {num: 4}} 199 | ]; 200 | packer.addArray(input); 201 | expect(packer.bins.length).toBe(2); 202 | }); 203 | 204 | test("adds the big rects & big hash first", () => { 205 | let input = [ 206 | {width: 600, height: 20, data: {num: 1}, hash: "aaa"}, 207 | {width: 600, height: 20, data: {num: 2}, hash: "bbb"}, 208 | {width: 1000, height: 1000, data: {num: 3}, hash: "ccc"}, 209 | {width: 1000, height: 1000, data: {num: 4}, hash: "ddd"} 210 | ]; 211 | packer.addArray(input); 212 | expect(packer.bins.length).toBe(2); 213 | expect(packer.bins[0].rects[0].hash).toBe("ddd"); 214 | expect(packer.bins[0].rects[1].hash).toBe("bbb"); 215 | }); 216 | 217 | test("add empty array", () => { 218 | packer.addArray([]); // test null array error 219 | expect(packer.bins.length).toBe(0); 220 | }); 221 | 222 | test("add one element array", () => { 223 | let input = [ 224 | {width: 600, height: 20, data: {num: 1}, hash: "aaa"} 225 | ]; 226 | packer.addArray(input); // test null array error 227 | expect(packer.bins.length).toBe(1); 228 | }); 229 | }); 230 | 231 | describe("#save & load", () => { 232 | test("Load old bins and continue packing", () => { 233 | packer.options.tag = true; 234 | let input = [ 235 | {width: 512, height: 512, data: {num: 1}}, 236 | {width: 512, height: 512, data: {num: 2}, tag: "one"}, 237 | {width: 512, height: 512, data: {num: 3}, tag: "one"}, 238 | {width: 512, height: 512, data: {num: 4}}, 239 | ]; 240 | packer.addArray(input); 241 | expect(packer.bins.length).toBe(2); 242 | expect(packer.bins[0].rects.length).toBe(2); 243 | expect(packer.bins[1].rects.length).toBe(2); 244 | let bins = packer.save(); 245 | expect(bins[0].rects.length).toBe(0); 246 | expect(bins[1].tag).toBe("one"); 247 | packer.load(bins); 248 | packer.addArray(input); 249 | expect(packer.bins.length).toBe(2); 250 | expect(packer.bins[0].rects.length).toBe(2); 251 | expect(packer.bins[1].rects.length).toBe(2); 252 | expect(packer.bins[1].tag).toBe("one"); 253 | }); 254 | }); 255 | 256 | describe("misc functionalities", () => { 257 | test("passes padding through", () => { 258 | packer = new MaxRectsPacker(1024, 1024, 4, opt); 259 | packer.add(500, 500, {num: 1}); 260 | packer.add(500, 500, {num: 1}); 261 | packer.add(500, 500, {num: 1}); 262 | expect(packer.bins[0].width).toBe(1004); 263 | }); 264 | 265 | test("get bins dirty status", () => { 266 | packer = new MaxRectsPacker(1024, 1024, 4, opt); 267 | packer.add(508, 508, {num: 1}); 268 | let tester1 = packer.add(new Rectangle(508, 508)); 269 | let tester2 = packer.add(508, 508, {num: 3}); 270 | expect(packer.dirty).toBe(true); 271 | for (let bin of packer.bins) bin.setDirty(false); 272 | expect(packer.dirty).toBe(false); 273 | tester1.height = 512; 274 | expect(packer.dirty).toBe(true); 275 | for (let bin of packer.bins) bin.setDirty(false); 276 | tester2.height = 512; 277 | expect(packer.dirty).toBe(true); 278 | }); 279 | 280 | test("quick repack & deep repack", () => { 281 | packer = new MaxRectsPacker(1024, 1024, 0, {...opt, ...{ tag: true }}); 282 | let rect = packer.add(1024, 512, {hash: "6"}); 283 | packer.add(512, 512, {hash: "5"}); 284 | packer.add(512, 512, {hash: "4"}); 285 | packer.add(512, 512, {hash: "3"}); 286 | packer.add({width: 512, height: 512, data: {hash: "2", tag: "one"}}); 287 | packer.add({width: 512, height: 512, data: {hash: "1"}, tag: "one"}); 288 | expect(packer.bins.length).toBe(3); 289 | for (let bin of packer.bins) bin.setDirty(false); // clean dirty status 290 | rect.width = 512; // shrink width 291 | packer.repack(); // quick repack 292 | expect(packer.bins.length).toBe(3); 293 | packer.repack(false); // deep repack 294 | expect(packer.bins.length).toBe(2); 295 | rect.height = 1024; // enlarge height 296 | rect.tag = "one"; 297 | packer.repack(); // quick repack 298 | expect(packer.bins.length).toBe(3); 299 | expect(packer.bins[2].tag).toBe("one"); 300 | packer.repack(false); // deep repack 301 | expect(packer.bins.length).toBe(2); 302 | }); 303 | 304 | test("Packer allow rotation", () => { 305 | packer = new MaxRectsPacker(500, 400, 1, {...opt, ...{ smart: false, allowRotation: true }}); 306 | packer.add(398, 98); 307 | packer.add(398, 98); 308 | packer.add(398, 98); 309 | let x = packer.add(398, 98); 310 | expect(x.rot).toBe(true); 311 | }); 312 | 313 | test("Per rectangle allow rotation", () => { 314 | packer = new MaxRectsPacker(500, 400, 1, {...opt, ...{ smart: false, allowRotation: true }}); 315 | packer.add(448, 98); 316 | packer.add(448, 98); 317 | packer.add(448, 98); 318 | packer.add(448, 98); 319 | // false overriding 320 | let x = packer.add(398, 48, { allowRotation: false }); 321 | expect(packer.bins.length).toBe(2); 322 | expect(x.rot).toBe(false); 323 | 324 | packer = new MaxRectsPacker(500, 400, 1, {...opt, ...{ smart: false, allowRotation: false}}); 325 | packer.add(448, 98); 326 | packer.add(448, 98); 327 | packer.add(448, 98); 328 | packer.add(448, 98); 329 | // true overriding 330 | x = packer.add(398, 48, { allowRotation: true }); 331 | expect(packer.bins.length).toBe(1); 332 | expect(x.rot).toBe(true); 333 | }); 334 | }); 335 | -------------------------------------------------------------------------------- /src/maxrects-packer.ts: -------------------------------------------------------------------------------- 1 | import { Rectangle, IRectangle } from "./geom/Rectangle"; 2 | import { MaxRectsBin } from "./maxrects-bin"; 3 | import { OversizedElementBin } from "./oversized-element-bin"; 4 | import { Bin, IBin } from "./abstract-bin"; 5 | import { EDGE_MAX_VALUE, EDGE_MIN_VALUE, PACKING_LOGIC, IOption } from "./types"; 6 | 7 | // Re-export types for backward compatibility 8 | export { EDGE_MAX_VALUE, EDGE_MIN_VALUE, PACKING_LOGIC, IOption } from "./types"; 9 | 10 | export class MaxRectsPacker { 11 | 12 | /** 13 | * The Bin array added to the packer 14 | */ 15 | public bins: Bin[]; 16 | 17 | /** 18 | * Options for MaxRect Packer 19 | * 20 | * @property smart - Smart sizing packer (default is true) 21 | * @property pot - use power of 2 sizing (default is true) 22 | * @property square - use square size (default is false) 23 | * @property allowRotation - allow rotation packing (default is false) 24 | * @property tag - allow auto grouping based on `rect.tag` (default is false) 25 | * @property 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) 26 | * @property border - atlas edge spacing (default is 0) 27 | * @property logic - MAX_AREA or MAX_EDGE based sorting logic (default is MAX_EDGE) 28 | */ 29 | public options: IOption = { 30 | smart: true, 31 | pot: true, 32 | square: false, 33 | allowRotation: false, 34 | tag: false, 35 | exclusiveTag: true, 36 | border: 0, 37 | logic: PACKING_LOGIC.MAX_EDGE 38 | }; 39 | 40 | /** 41 | * Creates an instance of MaxRectsPacker. 42 | * 43 | * @param width - width of the output atlas (default is 4096) 44 | * @param height - height of the output atlas (default is 4096) 45 | * @param padding - padding between glyphs/images (default is 0) 46 | * @param options - (Optional) packing options 47 | */ 48 | constructor ( 49 | public width: number = EDGE_MAX_VALUE, 50 | public height: number = EDGE_MAX_VALUE, 51 | public padding: number = 0, 52 | options: IOption = {} 53 | ) { 54 | this.bins = []; 55 | this.options = { ...this.options, ...options }; 56 | } 57 | 58 | /** 59 | * Add a bin/rectangle object with data to packer 60 | * 61 | * @param width - width of the input bin/rectangle 62 | * @param height - height of the input bin/rectangle 63 | * @param data - custom data object 64 | */ 65 | public add (width: number, height: number, data: any): T; 66 | /** 67 | * Add a bin/rectangle object extends IRectangle to packer 68 | * 69 | * @param rect - the rect object add to the packer bin 70 | */ 71 | public add (rect: T): T; 72 | public add (...args: any[]): any { 73 | if (args.length === 1) { 74 | if (typeof args[0] !== 'object') throw new Error("MacrectsPacker.add(): Wrong parameters"); 75 | const rect = args[0] as T; 76 | if (!((rect.width <= this.width && rect.height <= this.height) || (this.options.allowRotation && rect.width <= this.height && rect.height <= this.width))) { 77 | this.bins.push(new OversizedElementBin(rect)); 78 | } else { 79 | let added = this.bins.slice(this._currentBinIndex).find(bin => bin.add(rect) !== undefined); 80 | if (!added) { 81 | let bin = new MaxRectsBin(this.width, this.height, this.padding, this.options); 82 | let tag = (rect.data && rect.data.tag) ? rect.data.tag : rect.tag ? rect.tag : undefined; 83 | if (this.options.tag && tag) bin.tag = tag; 84 | bin.add(rect); 85 | this.bins.push(bin); 86 | } 87 | } 88 | return rect; 89 | } else { 90 | const rect: IRectangle = new Rectangle(args[0], args[1]); 91 | if (args.length > 2) rect.data = args[2]; 92 | 93 | if (!((rect.width <= this.width && rect.height <= this.height) || (this.options.allowRotation && rect.width <= this.height && rect.height <= this.width))) { 94 | this.bins.push(new OversizedElementBin(rect as T)); 95 | } else { 96 | let added = this.bins.slice(this._currentBinIndex).find(bin => bin.add(rect as T) !== undefined); 97 | if (!added) { 98 | let bin = new MaxRectsBin(this.width, this.height, this.padding, this.options); 99 | if (this.options.tag && rect.data.tag) bin.tag = rect.data.tag; 100 | bin.add(rect as T); 101 | this.bins.push(bin); 102 | } 103 | } 104 | return rect as T; 105 | } 106 | } 107 | 108 | /** 109 | * Add an Array of bins/rectangles to the packer. 110 | * 111 | * `Javascript`: Any object has property: { width, height, ... } is accepted. 112 | * 113 | * `Typescript`: object shall extends `MaxrectsPacker.IRectangle`. 114 | * 115 | * note: object has `hash` property will have more stable packing result 116 | * 117 | * @param rects - Array of bin/rectangles 118 | */ 119 | public addArray (rects: T[]) { 120 | if (!this.options.tag || this.options.exclusiveTag) { 121 | // if not using tag or using exclusiveTag, old approach 122 | this.sort(rects, this.options.logic).forEach(rect => this.add(rect)); 123 | } else { 124 | // sort rects by tags first 125 | if (rects.length === 0) return; 126 | rects.sort((a,b) => { 127 | const aTag = (a.data && a.data.tag) ? a.data.tag : a.tag ? a.tag : undefined; 128 | const bTag = (b.data && b.data.tag) ? b.data.tag : b.tag ? b.tag : undefined; 129 | return bTag === undefined ? -1 : aTag === undefined ? 1 : bTag > aTag ? -1 : 1; 130 | }); 131 | 132 | // iterate all bins to find the first bin which can place rects with same tag 133 | // 134 | let currentTag: any; 135 | let currentIdx: number = 0; 136 | let targetBin = this.bins.slice(this._currentBinIndex).find((bin, binIndex) => { 137 | let testBin = bin.clone(); 138 | for (let i = currentIdx; i < rects.length; i++) { 139 | const rect = rects[i]; 140 | const tag = (rect.data && rect.data.tag) ? rect.data.tag : rect.tag ? rect.tag : undefined; 141 | 142 | // initialize currentTag 143 | if (i === 0) currentTag = tag; 144 | 145 | if (tag !== currentTag) { 146 | // all current tag memeber tested successfully 147 | currentTag = tag; 148 | // do addArray() 149 | this.sort(rects.slice(currentIdx, i), this.options.logic).forEach(r => bin.add(r)); 150 | currentIdx = i; 151 | 152 | // recrusively addArray() with remaining rects 153 | this.addArray(rects.slice(i)); 154 | return true; 155 | } 156 | 157 | // remaining untagged rect will use normal addArray() 158 | if (tag === undefined) { 159 | // do addArray() 160 | this.sort(rects.slice(i), this.options.logic).forEach(r => this.add(r)); 161 | currentIdx = rects.length; 162 | // end test 163 | return true; 164 | } 165 | 166 | // still in the same tag group 167 | if (testBin.add(rect) === undefined) { 168 | // add the rects that could fit into the bins already 169 | // do addArray() 170 | this.sort(rects.slice(currentIdx, i), this.options.logic).forEach(r => bin.add(r)); 171 | currentIdx = i; 172 | 173 | // current bin cannot contain all tag members 174 | // procceed to test next bin 175 | return false; 176 | } 177 | } 178 | 179 | // all rects tested 180 | // do addArray() to the remaining tag group 181 | this.sort(rects.slice(currentIdx), this.options.logic).forEach(r => bin.add(r)); 182 | return true; 183 | }); 184 | 185 | // create a new bin if no current bin fit 186 | if (!targetBin) { 187 | const rect = rects[currentIdx]; 188 | const bin = new MaxRectsBin(this.width, this.height, this.padding, this.options); 189 | const tag = (rect.data && rect.data.tag) ? rect.data.tag : rect.tag ? rect.tag : undefined; 190 | if (this.options.tag && this.options.exclusiveTag && tag) bin.tag = tag; 191 | this.bins.push(bin); 192 | // Add the rect to the newly created bin 193 | bin.add(rect); 194 | currentIdx++; 195 | this.addArray(rects.slice(currentIdx)); 196 | } 197 | } 198 | } 199 | 200 | /** 201 | * Reset entire packer to initial states, keep settings 202 | */ 203 | public reset (): void { 204 | this.bins = []; 205 | this._currentBinIndex = 0; 206 | } 207 | 208 | /** 209 | * Repack all elements inside bins 210 | * 211 | * @param quick - quick repack only dirty bins (default is true) 212 | */ 213 | public repack (quick: boolean = true): void { 214 | if (quick) { 215 | let unpack: T[] = []; 216 | for (let bin of this.bins) { 217 | if (bin.dirty) { 218 | let up = bin.repack(); 219 | if (up) unpack.push(...up); 220 | } 221 | } 222 | this.addArray(unpack); 223 | return; 224 | } 225 | if (!this.dirty) return; 226 | const allRects = this.rects; 227 | this.reset(); 228 | this.addArray(allRects); 229 | } 230 | 231 | /** 232 | * Stop adding new element to the current bin and return a new bin. 233 | * 234 | * note: After calling `next()` all elements will no longer added to previous bins. 235 | * 236 | * @returns The current bin index 237 | */ 238 | public next (): number { 239 | this._currentBinIndex = this.bins.length; 240 | return this._currentBinIndex; 241 | } 242 | 243 | /** 244 | * Load bins to the packer, overwrite exist bins 245 | * 246 | * @param bins - MaxRectsBin objects 247 | */ 248 | public load (bins: IBin[]) { 249 | bins.forEach((bin, index) => { 250 | if (bin.maxWidth > this.width || bin.maxHeight > this.height) { 251 | this.bins.push(new OversizedElementBin(bin.width, bin.height, {})); 252 | } else { 253 | let newBin = new MaxRectsBin(this.width, this.height, this.padding, bin.options); 254 | newBin.freeRects.splice(0); 255 | bin.freeRects.forEach((r, i) => { 256 | newBin.freeRects.push(new Rectangle(r.width, r.height, r.x, r.y)); 257 | }); 258 | newBin.width = bin.width; 259 | newBin.height = bin.height; 260 | if (bin.tag) newBin.tag = bin.tag; 261 | this.bins[index] = newBin; 262 | } 263 | }, this); 264 | } 265 | 266 | /** 267 | * Output current bins to save 268 | */ 269 | public save (): IBin[] { 270 | let saveBins: IBin[] = []; 271 | this.bins.forEach((bin => { 272 | let saveBin: IBin = { 273 | width: bin.width, 274 | height: bin.height, 275 | maxWidth: bin.maxWidth, 276 | maxHeight: bin.maxHeight, 277 | freeRects: [], 278 | rects: [], 279 | options: bin.options 280 | }; 281 | if (bin.tag) saveBin = { ...saveBin, tag: bin.tag }; 282 | bin.freeRects.forEach(r => { 283 | saveBin.freeRects.push({ 284 | x: r.x, 285 | y: r.y, 286 | width: r.width, 287 | height: r.height 288 | }); 289 | }); 290 | saveBins.push(saveBin); 291 | })); 292 | return saveBins; 293 | } 294 | 295 | /** 296 | * Sort the given rects based on longest edge or surface area. 297 | * 298 | * If rects have the same sort value, will sort by second key `hash` if presented. 299 | * 300 | * @private 301 | * @param rects - array of rectangles to sort 302 | * @param logic - sorting logic, "area" or "edge" (default is MAX_EDGE) 303 | */ 304 | private sort (rects: T[], logic: IOption['logic'] = PACKING_LOGIC.MAX_EDGE) { 305 | return rects.slice().sort((a, b) => { 306 | const result = (logic === PACKING_LOGIC.MAX_EDGE) ? 307 | Math.max(b.width, b.height) - Math.max(a.width, a.height) : 308 | b.width * b.height - a.width * a.height; 309 | if (result === 0 && a.hash && b.hash) { 310 | return a.hash > b.hash ? -1 : 1; 311 | } else return result; 312 | }); 313 | } 314 | 315 | private _currentBinIndex: number = 0; 316 | /** 317 | * Return current functioning bin index, perior to this wont accept any new elements 318 | */ 319 | get currentBinIndex (): number { return this._currentBinIndex; } 320 | 321 | /** 322 | * Returns dirty status of all child bins 323 | */ 324 | get dirty (): boolean { return this.bins.some(bin => bin.dirty); } 325 | 326 | /** 327 | * Return all rectangles in this packer 328 | */ 329 | get rects (): T[] { 330 | let allRects: T[] = []; 331 | for (let bin of this.bins) { 332 | allRects.push(...bin.rects); 333 | } 334 | return allRects; 335 | } 336 | } 337 | -------------------------------------------------------------------------------- /test/maxrects-bin.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-constant-condition */ 2 | "use strict"; 3 | 4 | let MaxRectsBin = require("../src/maxrects-bin").MaxRectsBin; 5 | let Rectangle = require("../src/geom/Rectangle").Rectangle; 6 | 7 | const EDGE_MAX_VALUE = 4096; 8 | const EDGE_MIN_VALUE = 128; 9 | const opt = { 10 | smart: true, 11 | pot: true, 12 | square: false, 13 | allowRotation: false, 14 | tag: true, 15 | 16 | }; 17 | 18 | let bin; 19 | 20 | describe("no padding", () => { 21 | beforeEach(() => { 22 | bin = new MaxRectsBin(1024, 1024, 0, opt); 23 | }); 24 | 25 | test("is initially empty", () => { 26 | expect(bin.width).toBe(0); 27 | expect(bin.height).toBe(0); 28 | }); 29 | 30 | test("adds rects correctly", () => { 31 | let position = bin.add(200, 100, {}); 32 | expect(position.x).toBe(0); 33 | expect(position.y).toBe(0); 34 | }); 35 | 36 | test("edge case: only rotated version fits and should be set", () => { 37 | const edgeCaseBin = new MaxRectsBin(256, 1024, 0, {allowRotation: true, pot: false}); 38 | edgeCaseBin.add(260, 80); 39 | edgeCaseBin.add(260, 80); 40 | edgeCaseBin.add(260, 80); 41 | edgeCaseBin.add(260, 80); 42 | expect(edgeCaseBin.rects).toHaveLength(4); 43 | }); 44 | 45 | test("report/set bin dirty status", () => { 46 | bin.add(200, 100, {}); 47 | expect(bin.dirty).toBe(true); // add element to bin will render bin dirty 48 | bin.setDirty(false); 49 | expect(bin.dirty).toBe(false); // clean bin dirty 50 | bin.add(200, 100, {}); 51 | expect(bin.dirty).toBe(true); // add new element is dirty 52 | bin.setDirty(false); 53 | bin.setDirty(); 54 | expect(bin.dirty).toBe(true); // setDirty is dirty 55 | bin.reset(); 56 | expect(bin.dirty).toBe(false); // reset clean dirty 57 | let rect = bin.add(new Rectangle(200, 100)); 58 | bin.setDirty(false); 59 | rect.width = 256; 60 | expect(bin.dirty).toBe(true); // modify rects is dirty 61 | }); 62 | 63 | 64 | test("updates size correctly", () => { 65 | bin.add(200, 100, {}); 66 | expect(bin.width).toBe(256); 67 | expect(bin.height).toBe(128); 68 | }); 69 | 70 | test("stores data correctly", () => { 71 | bin.add(200, 100, {foo: "bar"}); 72 | expect(bin.rects[0].data.foo).toBe("bar"); 73 | }); 74 | 75 | test("set rotation correctly", () => { 76 | bin = new MaxRectsBin(1024, 1024, 0, {...opt, allowRotation: true}); 77 | bin.add({width: 512, height: 1024}); 78 | bin.add({width: 1024, height: 512}); 79 | expect(bin.rects.length).toBe(2); 80 | expect(bin.rects[1].rot).toBe(true); 81 | bin.reset(true); 82 | bin.add({width: 512, height: 1024}); 83 | bin.add({width: 1024, height: 512, rot: true}); 84 | expect(bin.rects.length).toBe(2); 85 | expect(bin.rects[1].rot).toBe(false); 86 | }); 87 | 88 | test("stores custom rect correctly", () => { 89 | bin.add({width: 200, height: 100, foo: "bar"}); 90 | expect(bin.rects[0].foo).toBe("bar"); 91 | }); 92 | 93 | test("none tag bin reject all tagged rects on exclusive tag mode", () => { 94 | bin.add({width: 200, height: 100}); 95 | bin.add({width: 200, height: 100, tag: "foo"}); 96 | bin.add({width: 200, height: 100, tag: "bar"}); 97 | expect(bin.rects.length).toBe(1); 98 | }); 99 | 100 | test("tagged bin reject different tagged rects on exclusive tag mode", () => { 101 | bin.tag = "foo"; 102 | let one = bin.add({width: 200, height: 100, tag: "foo"}); 103 | let two = bin.add({width: 200, height: 100, tag: "bar"}); 104 | expect(bin.rects.length).toBe(1); 105 | expect(bin.rects[0].tag).toBe("foo"); 106 | expect(two).toBeUndefined(); 107 | }); 108 | 109 | test("tagged bin accept different tagged rects on non-exclusive tag mode", () => { 110 | bin.tag = "foo"; 111 | bin.options.exclusiveTag = false; 112 | let one = bin.add({width: 200, height: 100, tag: "foo"}); 113 | let two = bin.add({width: 200, height: 100, tag: "bar"}); 114 | expect(bin.rects.length).toBe(2); 115 | expect(bin.rects[0].tag).toBe("foo"); 116 | expect(two).toBeDefined(); 117 | }); 118 | 119 | test("fits squares correctly", () => { 120 | let i = 0; 121 | while(bin.add(100, 100, {num: i})) { 122 | // circuit breaker 123 | if (i++ === 1000) { 124 | break; 125 | } 126 | } 127 | expect(i).toBe(100); 128 | expect(bin.rects.length).toBe(100); 129 | expect(bin.width).toBe(1024); 130 | expect(bin.height).toBe(1024); 131 | 132 | bin.rects.forEach((rect, i) => { 133 | expect(rect.data.num).toBe(i); 134 | }); 135 | }); 136 | 137 | test("reset & deep reset", () => { 138 | bin.add({width: 200, height: 100}); 139 | bin.add({width: 200, height: 100}); 140 | bin.add({width: 200, height: 100}); 141 | expect(bin.rects.length).toBe(3); 142 | expect(bin.width).toBe(512); 143 | bin.reset(); 144 | expect(bin.width).toBe(0); 145 | expect(bin.freeRects.length).toBe(1); 146 | let unpacked = bin.repack(); 147 | expect(unpacked).toBeUndefined(); 148 | expect(bin.width).toBe(512); 149 | bin.reset(true); 150 | expect(bin.width).toBe(0); 151 | expect(bin.rects.length).toBe(0); 152 | expect(bin.options.tag).toBe(true); 153 | bin.reset(true, true); 154 | expect(bin.options.tag).toBe(false); 155 | }); 156 | 157 | test("repack", () => { 158 | let rect1 = bin.add({width: 512, height: 512, id: "one"}); 159 | let rect2 = bin.add({width: 512, height: 512, id: "two"}); 160 | let rect3 = bin.add({width: 512, height: 512, id: "three"}); 161 | rect2.width = 1024; 162 | rect2.height = 513; 163 | let unpacked = bin.repack(); 164 | expect(unpacked.length).toBe(2); 165 | expect(unpacked[0].id).toBe("one"); 166 | expect(unpacked[1].id).toBe("three"); 167 | expect(bin.rects.length).toBe(1); 168 | }); 169 | 170 | test("monkey testing", () => { 171 | let rects = []; 172 | while (true) { 173 | let width = Math.floor(Math.random() * 200); 174 | let height = Math.floor(Math.random() * 200); 175 | let rect = new Rectangle(width, height); 176 | 177 | let position = bin.add(rect); 178 | if (position) { 179 | expect(position.width).toBe(width); 180 | expect(position.height).toBe(height); 181 | rects.push(position); 182 | } else { 183 | break; 184 | } 185 | } 186 | 187 | expect(bin.width).toBeLessThanOrEqual(1024); 188 | expect(bin.height).toBeLessThanOrEqual(1024); 189 | 190 | rects.forEach(rect1 => { 191 | // Make sure rects are not overlapping 192 | rects.forEach(rect2 => { 193 | if (rect1 !== rect2) { 194 | expect(rect1.collide(rect2)).toBe(false, "intersection detected: " + JSON.stringify(rect1) + " " + JSON.stringify(rect2)); 195 | } 196 | }); 197 | 198 | // Make sure no rect is outside bounds 199 | expect(rect1.x + rect1.width).toBeLessThanOrEqual(bin.width); 200 | expect(rect1.y + rect1.height).toBeLessThanOrEqual(bin.height); 201 | }); 202 | }); 203 | }); 204 | 205 | let padding = 4; 206 | 207 | describe("padding", () => { 208 | beforeEach(() => { 209 | bin = new MaxRectsBin(1024, 1024, padding, opt); 210 | }); 211 | 212 | test("is initially empty", () => { 213 | expect(bin.width).toBe(0); 214 | expect(bin.height).toBe(0); 215 | }); 216 | 217 | test("handles padding correctly", () => { 218 | bin.add(512, 512, {}); 219 | bin.add(512 - padding, 512, {}); 220 | bin.add(512, 512 - padding, {}); 221 | expect(bin.width).toBe(1024); 222 | expect(bin.height).toBe(1024); 223 | expect(bin.rects.length).toBe(3); 224 | }); 225 | 226 | test("adds rects with sizes close to the max", () => { 227 | expect(bin.add(1024, 1024)).toBeDefined(); 228 | expect(bin.rects.length).toBe(1); 229 | }); 230 | 231 | test("edge case: multiple rects with slightly bigger size then maxWidth should be placed rotated", () => { 232 | const edgeCaseBin = new MaxRectsBin(256, 1024, padding, {allowRotation: true, pot: false, square: false, smart: true}); 233 | edgeCaseBin.add(260, 80); 234 | edgeCaseBin.add(260, 80); 235 | edgeCaseBin.add(260, 80); 236 | edgeCaseBin.add(260, 80); 237 | 238 | expect(edgeCaseBin.rects).toHaveLength(4); 239 | expect(edgeCaseBin.rects[3].rot).toBeTruthy(); 240 | expect(edgeCaseBin.rects[3].width).toBe(80); 241 | }); 242 | 243 | test("monkey testing", () => { 244 | // bin = new MaxRectsBin(1024, 1024, 40); 245 | let rects = []; 246 | while (true) { 247 | let width = Math.floor(Math.random() * 200); 248 | let height = Math.floor(Math.random() * 200); 249 | let rect = new Rectangle(width, height); 250 | 251 | let position = bin.add(rect); 252 | if (position) { 253 | expect(position.width).toBe(width); 254 | expect(position.height).toBe(height); 255 | rects.push(position); 256 | } else { 257 | break; 258 | } 259 | } 260 | 261 | expect(bin.width).toBeLessThanOrEqual(1024); 262 | expect(bin.height).toBeLessThanOrEqual(1024); 263 | 264 | rects.forEach(rect1 => { 265 | // Make sure rects are not overlapping 266 | rects.forEach(rect2 => { 267 | if (rect1 !== rect2) { 268 | try { 269 | expect(rect1.collide(rect2)).toBe(false); 270 | } catch (e) { 271 | throw new Error("intersection detected: " + JSON.stringify(rect1) + " " + JSON.stringify(rect2)); 272 | } 273 | } 274 | }); 275 | 276 | // Make sure no rect is outside bounds 277 | expect(rect1.x).toBeGreaterThanOrEqual(0); 278 | expect(rect1.y).toBeGreaterThanOrEqual(0); 279 | expect(rect1.x + rect1.width).toBeLessThanOrEqual(bin.width); 280 | expect(rect1.y + rect1.height).toBeLessThanOrEqual(bin.height); 281 | }); 282 | }); 283 | }); 284 | 285 | padding = 4; 286 | let border = 5; 287 | 288 | describe("border", () => { 289 | beforeEach(() => { 290 | const borderOpt = {...opt, ...{border: border, square: false}}; 291 | bin = new MaxRectsBin(1024, 1024, padding, borderOpt); 292 | }); 293 | 294 | test("is initially empty", () => { 295 | expect(bin.width).toBe(0); 296 | expect(bin.height).toBe(0); 297 | }); 298 | 299 | test("handles border & padding correctly", () => { 300 | let size = 512 - border * 2; // 301 | let pos1 = bin.add(size + 1, size, {}); 302 | expect(pos1.x).toBe(5); 303 | expect(pos1.y).toBe(5); 304 | expect(bin.width).toBe(1024); 305 | expect(bin.height).toBe(512); 306 | let pos2 = bin.add(size, size, {}); 307 | expect(pos2.x - pos1.x - pos1.width).toBe(padding); // handle space correctly 308 | expect(pos2.y).toBe(border); 309 | expect(bin.width).toBe(1024); 310 | expect(bin.height).toBe(512); 311 | bin.add(size, size, {}); 312 | bin.add(512, 508, {}); 313 | expect(bin.width).toBe(1024); 314 | expect(bin.height).toBe(1024); 315 | expect(bin.rects.length).toBe(3); 316 | }); 317 | 318 | test("adds rects with sizes close to the max", () => { 319 | expect(bin.add(1024, 1024)).toBeUndefined(); 320 | expect(bin.rects.length).toBe(0); 321 | }); 322 | 323 | let repeat = 5; 324 | test(`super monkey testing (${repeat} loop)`, () => { 325 | while (repeat > 0) { 326 | padding = Math.floor(Math.random() * 10); 327 | border = Math.floor(Math.random() * 20); 328 | const borderOpt = {...opt, ...{border: border, square: false}}; 329 | bin = new MaxRectsBin(1024, 1024, padding, borderOpt); 330 | 331 | let rects = []; 332 | while (true) { 333 | let width = Math.floor(Math.random() * 200); 334 | let height = Math.floor(Math.random() * 200); 335 | let rect = new Rectangle(width, height); 336 | 337 | let position = bin.add(rect); 338 | if (position) { 339 | expect(position.width).toBe(width); 340 | expect(position.height).toBe(height); 341 | rects.push(position); 342 | } else { 343 | break; 344 | } 345 | } 346 | 347 | expect(bin.width).toBeLessThanOrEqual(1024); 348 | expect(bin.height).toBeLessThanOrEqual(1024); 349 | 350 | rects.forEach(rect1 => { 351 | // Make sure rects are not overlapping 352 | rects.forEach(rect2 => { 353 | if (rect1 !== rect2) { 354 | try { 355 | expect(rect1.collide(rect2)).toBe(false); 356 | } catch (e) { 357 | throw new Error("intersection detected: " + JSON.stringify(rect1) + " " + JSON.stringify(rect2)); 358 | } 359 | } 360 | }); 361 | 362 | // Make sure no rect is outside bounds 363 | expect(rect1.x).toBeGreaterThanOrEqual(bin.options.border); 364 | expect(rect1.y).toBeGreaterThanOrEqual(bin.options.border); 365 | expect(rect1.x + rect1.width).toBeLessThanOrEqual(bin.width - bin.options.border); 366 | expect(rect1.y + rect1.height).toBeLessThanOrEqual(bin.height - bin.options.border); 367 | }); 368 | repeat --; 369 | } 370 | }); 371 | }); 372 | 373 | describe("logic FILL_WIDTH", () => { 374 | beforeEach(() => { 375 | bin = new MaxRectsBin(1024, 512, 0, {allowRotation: true, logic: 2, pot: false, square: false}); 376 | }); 377 | 378 | test("sets all elements along width with the smallest height", () => { 379 | /** 380 | * Visualize the placement result 381 | * _______________________ 382 | * | ███ ███ ███ | 383 | * ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ 384 | */ 385 | let position1 = bin.add(300, 50, {}); 386 | let position2 = bin.add(50, 300, {}); 387 | let position3 = bin.add(300, 50, {}); 388 | expect(position1.x).toBe(0); 389 | expect(position1.y).toBe(0); 390 | expect(position2.x).toBe(300); 391 | expect(position2.y).toBe(0); 392 | expect(position3.x).toBe(600); 393 | expect(position3.y).toBe(0); 394 | expect(bin.width).toBe(900); 395 | expect(bin.height).toBe(50); 396 | }); 397 | 398 | test("adds rects correctly with rotation", () => { 399 | /** 400 | * Visualize the placement result (1 vertical at the end) 401 | * _______________________ 402 | * | ███ ███ ███ █ | 403 | * | ███ ███ ███ █ | 404 | * | ███ ███ ███ █ | 405 | * | ██████ | 406 | * ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ 407 | */ 408 | const rects = [ 409 | [300, 100], 410 | [100, 300], 411 | [300, 100], 412 | [300, 100], 413 | [100, 300], 414 | [300, 100], 415 | [300, 100], 416 | [100, 300], 417 | [300, 100], 418 | [300, 100], 419 | [300, 100], 420 | [100, 600], 421 | ]; 422 | rects.forEach(rect => bin.add(rect[0], rect[1])); 423 | expect([bin.rects[0].x, bin.rects[0].y]).toEqual([0, 0]); 424 | expect([bin.rects[1].x, bin.rects[1].y]).toEqual([300, 0]); 425 | expect([bin.rects[2].x, bin.rects[2].y]).toEqual([600, 0]); 426 | expect([bin.rects[3].x, bin.rects[3].y]).toEqual([0, 100]); 427 | expect([bin.rects[4].x, bin.rects[4].y]).toEqual([300, 100]); 428 | expect([bin.rects[5].x, bin.rects[5].y]).toEqual([600, 100]); 429 | expect([bin.rects[6].x, bin.rects[6].y]).toEqual([900, 0]); 430 | expect([bin.rects[7].x, bin.rects[7].y]).toEqual([0, 200]); 431 | expect([bin.rects[8].x, bin.rects[8].y]).toEqual([300, 200]); 432 | expect([bin.rects[9].x, bin.rects[9].y]).toEqual([600, 200]); 433 | expect([bin.rects[10].x, bin.rects[10].y]).toEqual([0, 300]); 434 | expect([bin.rects[11].x, bin.rects[11].y]).toEqual([300, 300]); 435 | expect(bin.width).toBe(1000); 436 | expect(bin.height).toBe(400); 437 | }); 438 | 439 | }); 440 | -------------------------------------------------------------------------------- /src/maxrects-bin.ts: -------------------------------------------------------------------------------- 1 | import { EDGE_MAX_VALUE, PACKING_LOGIC, IOption } from "./types"; 2 | import { Rectangle, IRectangle } from "./geom/Rectangle"; 3 | import { Bin } from "./abstract-bin"; 4 | 5 | export class MaxRectsBin extends Bin { 6 | public width: number; 7 | public height: number; 8 | public freeRects: Rectangle[] = []; 9 | public rects: T[] = []; 10 | private verticalExpand: boolean = false; 11 | private stage: Rectangle; 12 | private border: number; 13 | public options: IOption = { 14 | smart: true, 15 | pot: true, 16 | square: false, 17 | allowRotation: false, 18 | tag: false, 19 | exclusiveTag: true, 20 | border: 0, 21 | logic: PACKING_LOGIC.MAX_EDGE 22 | }; 23 | 24 | constructor ( 25 | public maxWidth: number = EDGE_MAX_VALUE, 26 | public maxHeight: number = EDGE_MAX_VALUE, 27 | public padding: number = 0, 28 | options: IOption = {} 29 | ) { 30 | super(); 31 | this.options = { ...this.options, ...options }; 32 | this.width = this.options.smart ? 0 : maxWidth; 33 | this.height = this.options.smart ? 0 : maxHeight; 34 | this.border = this.options.border ? this.options.border : 0; 35 | this.freeRects.push(new Rectangle( 36 | this.maxWidth + this.padding - this.border * 2, 37 | this.maxHeight + this.padding - this.border * 2, 38 | this.border, 39 | this.border)); 40 | this.stage = new Rectangle(this.width, this.height); 41 | } 42 | 43 | public add (rect: T): T | undefined; 44 | public add (width: number, height: number, data: any): T | undefined; 45 | public add (...args: any[]): any { 46 | let data: any; 47 | let rect: IRectangle; 48 | if (args.length === 1) { 49 | if (typeof args[0] !== 'object') throw new Error("MacrectsBin.add(): Wrong parameters"); 50 | rect = args[0] as T; 51 | // Check if rect.tag match bin.tag, if bin.tag not defined, it will accept any rect 52 | let tag = (rect.data && rect.data.tag) ? rect.data.tag : rect.tag ? rect.tag : undefined; 53 | if (this.options.tag && this.options.exclusiveTag && this.tag !== tag) return undefined; 54 | } else { 55 | data = args.length > 2 ? args[2] : null; 56 | // Check if data.tag match bin.tag, if bin.tag not defined, it will accept any rect 57 | if (this.options.tag && this.options.exclusiveTag) { 58 | if (data && this.tag !== data.tag) return undefined; 59 | if (!data && this.tag) return undefined; 60 | } 61 | rect = new Rectangle(args[0], args[1]); 62 | rect.data = data; 63 | rect.setDirty(false); 64 | } 65 | 66 | const result = this.place(rect); 67 | if (result) this.rects.push(result); 68 | return result; 69 | } 70 | 71 | public repack (): T[] | undefined { 72 | let unpacked: T[] = []; 73 | this.reset(); 74 | // re-sort rects from big to small 75 | this.rects.sort((a, b) => { 76 | const result = Math.max(b.width, b.height) - Math.max(a.width, a.height); 77 | if (result === 0 && a.hash && b.hash) { 78 | return a.hash > b.hash ? -1 : 1; 79 | } else return result; 80 | }); 81 | for (let rect of this.rects) { 82 | if (!this.place(rect)) { 83 | unpacked.push(rect); 84 | } 85 | } 86 | for (let rect of unpacked) this.rects.splice(this.rects.indexOf(rect), 1); 87 | return unpacked.length > 0 ? unpacked : undefined; 88 | } 89 | 90 | public reset (deepReset: boolean = false, resetOption: boolean = false): void { 91 | if (deepReset) { 92 | if (this.data) delete this.data; 93 | if (this.tag) delete this.tag; 94 | this.rects = []; 95 | if (resetOption) { 96 | this.options = { 97 | smart: true, 98 | pot: true, 99 | square: true, 100 | allowRotation: false, 101 | tag: false, 102 | border: 0 103 | }; 104 | } 105 | } 106 | this.width = this.options.smart ? 0 : this.maxWidth; 107 | this.height = this.options.smart ? 0 : this.maxHeight; 108 | this.border = this.options.border ? this.options.border : 0; 109 | this.freeRects = [new Rectangle( 110 | this.maxWidth + this.padding - this.border * 2, 111 | this.maxHeight + this.padding - this.border * 2, 112 | this.border, 113 | this.border)]; 114 | this.stage = new Rectangle(this.width, this.height); 115 | this._dirty = 0; 116 | } 117 | 118 | public clone (): MaxRectsBin { 119 | let clonedBin: MaxRectsBin = new MaxRectsBin(this.maxWidth, this.maxHeight, this.padding, this.options); 120 | for (let rect of this.rects) { 121 | clonedBin.add(rect); 122 | } 123 | return clonedBin; 124 | } 125 | 126 | private place (rect: IRectangle): T | undefined { 127 | // recheck if tag matched 128 | let tag = (rect.data && rect.data.tag) ? rect.data.tag : rect.tag ? rect.tag : undefined; 129 | if (this.options.tag && this.options.exclusiveTag && this.tag !== tag) return undefined; 130 | 131 | let node: IRectangle | undefined; 132 | let allowRotation: boolean | undefined; 133 | // getter/setter do not support hasOwnProperty() 134 | if (rect.hasOwnProperty("_allowRotation") && rect.allowRotation !== undefined) { 135 | allowRotation = rect.allowRotation; // Per Rectangle allowRotation override packer settings 136 | } else { 137 | allowRotation = this.options.allowRotation; 138 | } 139 | node = this.findNode(rect.width + this.padding, rect.height + this.padding, allowRotation); 140 | 141 | if (node) { 142 | this.updateBinSize(node); 143 | let numRectToProcess = this.freeRects.length; 144 | let i: number = 0; 145 | while (i < numRectToProcess) { 146 | if (this.splitNode(this.freeRects[i], node)) { 147 | this.freeRects.splice(i, 1); 148 | numRectToProcess--; 149 | i--; 150 | } 151 | i++; 152 | } 153 | this.pruneFreeList(); 154 | this.verticalExpand = this.options.logic === PACKING_LOGIC.FILL_WIDTH ? false : this.width > this.height ? true : false; 155 | rect.x = node.x; 156 | rect.y = node.y; 157 | if (rect.rot === undefined) rect.rot = false; 158 | rect.rot = node.rot ? !rect.rot : rect.rot; 159 | this._dirty ++; 160 | return rect as T; 161 | } else if (!this.verticalExpand) { 162 | if (this.updateBinSize(new Rectangle( 163 | rect.width + this.padding, rect.height + this.padding, 164 | this.width + this.padding - this.border, this.border 165 | )) || this.updateBinSize(new Rectangle( 166 | rect.width + this.padding, rect.height + this.padding, 167 | this.border, this.height + this.padding - this.border 168 | ))) { 169 | return this.place(rect); 170 | } 171 | } else { 172 | if (this.updateBinSize(new Rectangle( 173 | rect.width + this.padding, rect.height + this.padding, 174 | this.border, this.height + this.padding - this.border 175 | )) || this.updateBinSize(new Rectangle( 176 | rect.width + this.padding, rect.height + this.padding, 177 | this.width + this.padding - this.border, this.border 178 | ))) { 179 | return this.place(rect); 180 | } 181 | } 182 | return undefined; 183 | } 184 | 185 | /** 186 | * Find the best rect out of freeRects 187 | * This method has different logics to resolve the best rect. 188 | * @param width 189 | * @param height 190 | * @param allowRotation 191 | * @returns Rectangle of the best rect for placement 192 | */ 193 | private findNode (width: number, height: number, allowRotation?: boolean): Rectangle | undefined { 194 | // scoring based on one single number. The smaller the better the choice. 195 | let score: number = Number.MAX_VALUE; 196 | let areaFit: number; 197 | let r: Rectangle; 198 | let bestNode: Rectangle | undefined; 199 | for (let i in this.freeRects) { 200 | r = this.freeRects[i]; 201 | if (r.width >= width && r.height >= height) { 202 | if (this.options.logic === PACKING_LOGIC.MAX_AREA) { 203 | // the rect with the lowest rest area wins 204 | areaFit = r.width * r.height - width * height; 205 | } else if (this.options.logic === PACKING_LOGIC.FILL_WIDTH) { 206 | // this logic needs to factors to build a score. 207 | // 1. rect position: choose the most rightest one with the lowest y coordinate. 208 | // 2. size that needs to grow to place the element. The lower the better score (leads to optimal rotation placement) 209 | 210 | 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 211 | 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. 212 | 213 | // calculate how much space will be add to total height 214 | const heightToGain = r.y + height - this.height; 215 | 216 | areaFit = numberOfBetterRects + heightToGain; // add both factors together 217 | } else { 218 | // the rect with the lowest loss of either width or height wins 219 | areaFit = Math.min(r.width - width, r.height - height); 220 | } 221 | if (areaFit < score) { 222 | bestNode = new Rectangle(width, height, r.x, r.y); 223 | score = areaFit; 224 | } 225 | } 226 | 227 | if (!allowRotation) continue; 228 | 229 | // Continue to test 90-degree rotated rectangle 230 | if (r.width >= height && r.height >= width) { 231 | if (this.options.logic === PACKING_LOGIC.MAX_AREA) { 232 | areaFit = r.width * r.height - height * width; 233 | } else if (this.options.logic === PACKING_LOGIC.FILL_WIDTH) { 234 | const currentRectPositionScore = r.x + r.y * this.maxWidth; 235 | 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. 236 | 237 | // calculate how much space will be add to total height 238 | const heightToGain = r.y + width - this.height; 239 | 240 | areaFit = numberOfBetterRects + heightToGain; // add both factors together 241 | } else { 242 | areaFit = Math.min(r.height - width, r.width - height); 243 | } 244 | if (areaFit < score) { 245 | bestNode = new Rectangle(height, width, r.x, r.y, true); // Rotated node 246 | score = areaFit; 247 | } 248 | } 249 | } 250 | return bestNode; 251 | } 252 | 253 | private splitNode (freeRect: IRectangle, usedNode: IRectangle): boolean { 254 | // Test if usedNode intersect with freeRect 255 | if (!freeRect.collide(usedNode)) return false; 256 | 257 | // Do vertical split 258 | if (usedNode.x < freeRect.x + freeRect.width && usedNode.x + usedNode.width > freeRect.x) { 259 | // New node at the top side of the used node 260 | if (usedNode.y > freeRect.y && usedNode.y < freeRect.y + freeRect.height) { 261 | let newNode: Rectangle = new Rectangle(freeRect.width, usedNode.y - freeRect.y, freeRect.x, freeRect.y); 262 | this.freeRects.push(newNode); 263 | } 264 | // New node at the bottom side of the used node 265 | if (usedNode.y + usedNode.height < freeRect.y + freeRect.height) { 266 | let newNode = new Rectangle( 267 | freeRect.width, 268 | freeRect.y + freeRect.height - (usedNode.y + usedNode.height), 269 | freeRect.x, 270 | usedNode.y + usedNode.height 271 | ); 272 | this.freeRects.push(newNode); 273 | } 274 | } 275 | 276 | // Do Horizontal split 277 | if (usedNode.y < freeRect.y + freeRect.height && 278 | usedNode.y + usedNode.height > freeRect.y) { 279 | // New node at the left side of the used node. 280 | if (usedNode.x > freeRect.x && usedNode.x < freeRect.x + freeRect.width) { 281 | let newNode = new Rectangle(usedNode.x - freeRect.x, freeRect.height, freeRect.x, freeRect.y); 282 | this.freeRects.push(newNode); 283 | } 284 | // New node at the right side of the used node. 285 | if (usedNode.x + usedNode.width < freeRect.x + freeRect.width) { 286 | let newNode = new Rectangle( 287 | freeRect.x + freeRect.width - (usedNode.x + usedNode.width), 288 | freeRect.height, 289 | usedNode.x + usedNode.width, 290 | freeRect.y 291 | ); 292 | this.freeRects.push(newNode); 293 | } 294 | } 295 | return true; 296 | } 297 | 298 | private pruneFreeList () { 299 | // Go through each pair of freeRects and remove any rects that is redundant 300 | let i: number = 0; 301 | let j: number = 0; 302 | let len: number = this.freeRects.length; 303 | while (i < len) { 304 | j = i + 1; 305 | let tmpRect1 = this.freeRects[i]; 306 | while (j < len) { 307 | let tmpRect2 = this.freeRects[j]; 308 | if (tmpRect2.contain(tmpRect1)) { 309 | this.freeRects.splice(i, 1); 310 | i--; 311 | len--; 312 | break; 313 | } 314 | if (tmpRect1.contain(tmpRect2)) { 315 | this.freeRects.splice(j, 1); 316 | j--; 317 | len--; 318 | } 319 | j++; 320 | } 321 | i++; 322 | } 323 | } 324 | 325 | private updateBinSize (node: IRectangle): boolean { 326 | if (!this.options.smart) return false; 327 | if (this.stage.contain(node)) return false; 328 | let tmpWidth: number = Math.max(this.width, node.x + node.width - this.padding + this.border); 329 | let tmpHeight: number = Math.max(this.height, node.y + node.height - this.padding + this.border); 330 | let tmpFits: boolean = !(tmpWidth > this.maxWidth || tmpHeight > this.maxHeight); 331 | if (this.options.allowRotation) { 332 | // do extra test on rotated node whether it's a better choice 333 | const rotWidth: number = Math.max(this.width, node.x + node.height - this.padding + this.border); 334 | const rotHeight: number = Math.max(this.height, node.y + node.width - this.padding + this.border); 335 | const rotFits: boolean = !(rotWidth > this.maxWidth || rotHeight > this.maxHeight); 336 | // only compare when both rects will fit into bin 337 | if (tmpFits && rotFits && rotWidth * rotHeight < tmpWidth * tmpHeight) { 338 | tmpWidth = rotWidth; 339 | tmpHeight = rotHeight; 340 | } 341 | // if rot fits and tmpFits not then do not compare area and set rot directly (some cases area of not rotated is smaller but will not fit) 342 | if (rotFits && !tmpFits) { 343 | tmpWidth = rotWidth; 344 | tmpHeight = rotHeight; 345 | } 346 | } 347 | if (this.options.pot) { 348 | tmpWidth = Math.pow(2, Math.ceil(Math.log(tmpWidth) * Math.LOG2E)); 349 | tmpHeight = Math.pow(2, Math.ceil(Math.log(tmpHeight) * Math.LOG2E)); 350 | } 351 | if (this.options.square) { 352 | tmpWidth = tmpHeight = Math.max(tmpWidth, tmpHeight); 353 | } 354 | tmpFits = !(tmpWidth > this.maxWidth || tmpHeight > this.maxHeight); 355 | if (!tmpFits) { 356 | return false; 357 | } 358 | this.expandFreeRects(tmpWidth + this.padding, tmpHeight + this.padding); 359 | this.width = this.stage.width = tmpWidth; 360 | this.height = this.stage.height = tmpHeight; 361 | return true; 362 | } 363 | 364 | private expandFreeRects (width: number, height: number) { 365 | this.freeRects.forEach((freeRect, index) => { 366 | if (freeRect.x + freeRect.width >= Math.min(this.width + this.padding - this.border, width)) { 367 | freeRect.width = width - freeRect.x - this.border; 368 | } 369 | if (freeRect.y + freeRect.height >= Math.min(this.height + this.padding - this.border, height)) { 370 | freeRect.height = height - freeRect.y - this.border; 371 | } 372 | }, this); 373 | this.freeRects.push(new Rectangle( 374 | width - this.width - this.padding, 375 | height - this.border * 2, 376 | this.width + this.padding - this.border, 377 | this.border)); 378 | this.freeRects.push(new Rectangle( 379 | width - this.border * 2, 380 | height - this.height - this.padding, 381 | this.border, 382 | this.height + this.padding - this.border)); 383 | this.freeRects = this.freeRects.filter(freeRect => { 384 | return !(freeRect.width <= 0 || freeRect.height <= 0 || freeRect.x < this.border || freeRect.y < this.border); 385 | }); 386 | this.pruneFreeList(); 387 | } 388 | } 389 | --------------------------------------------------------------------------------