├── image-resource └── max-rect-time.png ├── test ├── data.js ├── rects.json ├── index.js ├── show-result.html ├── charts.html ├── index.ts └── rects1.json ├── .editorconfig ├── tslint.json ├── src ├── index.ts ├── rect.ts ├── search.ts ├── genetic.ts └── max-rect-bin-pack.ts ├── tsconfig.json ├── tsconfig.esm.json ├── LICENSE ├── .gitignore ├── package.json └── README.md /image-resource/max-rect-time.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ant-tinyjs/bin-packing-core/HEAD/image-resource/max-rect-time.png -------------------------------------------------------------------------------- /test/data.js: -------------------------------------------------------------------------------- 1 | var data = [{"x":0,"y":0,"width":485,"height":869,"isRotated":false},{"x":0,"y":869,"width":96,"height":104,"isRotated":false}] -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /test/rects.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "width": 211, "height": 137 }, 3 | { "width": 259, "height": 165 }, 4 | { "width": 266, "height": 122 }, 5 | { "width": 203, "height": 115 }, 6 | { "width": 265, "height": 162 }, 7 | { "width": 261, "height": 106 } 8 | ] 9 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended"], 4 | "jsRules": {}, 5 | "rules": { 6 | "arrow-parens": false, 7 | "quotemark": [true, "single", "avoid-escape", "avoid-template"], 8 | "no-var-requires": false, 9 | "unified-signatures": false 10 | }, 11 | "rulesDirectory": [] 12 | } 13 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import genetic, { Genetic, IGeneticOptions } from './genetic'; 2 | import MaxRectBinPack, { FindPosition } from './max-rect-bin-pack'; 3 | import Rect from './rect'; 4 | import { Search } from './search'; 5 | 6 | export { 7 | FindPosition, 8 | Genetic, // 遗传算法类 9 | MaxRectBinPack, 10 | Rect, 11 | genetic, // 包装方法 12 | Search, 13 | }; 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "compilerOptions": { 4 | "allowSyntheticDefaultImports": true, 5 | "esModuleInterop": true, 6 | "allowJs": false, 7 | "alwaysStrict": true, 8 | "importHelpers": false, 9 | "lib": [ 10 | "es5", 11 | "es6" 12 | ], 13 | "module": "umd", 14 | "moduleResolution": "node", 15 | "noEmitOnError": true, 16 | "noImplicitThis": true, 17 | "noImplicitAny": true, 18 | "outDir": "./lib", 19 | "sourceMap": true, 20 | "target": "es5", 21 | "typeRoots": [ 22 | "types", 23 | "node_modules/@types" 24 | ] 25 | }, 26 | "include": [ 27 | "src" 28 | ], 29 | "exclude": [ 30 | "node_modules" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "compilerOptions": { 4 | "allowSyntheticDefaultImports": true, 5 | "esModuleInterop": true, 6 | "allowJs": false, 7 | "alwaysStrict": true, 8 | "importHelpers": false, 9 | "lib": [ 10 | "es5", 11 | "es6" 12 | ], 13 | "module": "es6", 14 | "moduleResolution": "node", 15 | "noEmitOnError": true, 16 | "noImplicitThis": true, 17 | "noImplicitAny": true, 18 | "outDir": "./esm", 19 | "sourceMap": true, 20 | "target": "es5", 21 | "typeRoots": [ 22 | "types", 23 | "node_modules/@types" 24 | ] 25 | }, 26 | "include": [ 27 | "src" 28 | ], 29 | "exclude": [ 30 | "node_modules" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | var FindPosition = require('./../lib/index').FindPosition; 2 | var Genetic = require('./../lib/index').Genetic; 3 | var genetic = require('./../lib/index').genetic; 4 | var MaxRectBinPack = require('./../lib/index').MaxRectBinPack; 5 | var Rect = require('./../lib/index').Rect; 6 | 7 | 8 | 9 | const findPosition = 0; // 参照第一条 10 | 11 | const bestSize = genetic(rects, { 12 | // 遗传算法确定最优策略 13 | findPosition: findPosition, 14 | lifeTimes: 50, // 代数 15 | liveRate: 0.5, // 存活率 16 | size: 50, // 每一代孩子个数 17 | }); 18 | 19 | const width = bestSize.x; 20 | const height = bestSize.y; 21 | const packer = new MaxRectBinPack(width, height, true); 22 | const result = packer.insertRects(rects, findPosition); 23 | console.log(result); 24 | console.log(result.length === 10); 25 | -------------------------------------------------------------------------------- /test/show-result.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Document 9 | 10 | 11 | 12 | 13 | 14 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/rect.ts: -------------------------------------------------------------------------------- 1 | class Rect { 2 | /** 3 | * 起点 x 坐标 4 | */ 5 | public x: number = 0; 6 | /** 7 | * 起点 y 坐标 8 | */ 9 | public y: number = 0; 10 | /** 11 | * 宽度 12 | */ 13 | public width: number = 0; 14 | /** 15 | * 高度 16 | */ 17 | public height: number = 0; 18 | /** 19 | * 当前是否被旋转了 20 | */ 21 | public isRotated: boolean = false; 22 | /** 23 | * 自定义信息 24 | */ 25 | public info: any; 26 | /** 27 | * 克隆 28 | */ 29 | public clone(): Rect { 30 | const cloned = new Rect(); 31 | cloned.x = this.x; 32 | cloned.y = this.y; 33 | cloned.height = this.height; 34 | cloned.width = this.width; 35 | cloned.info = this.info; 36 | return cloned; 37 | } 38 | /** 39 | * 矩形是否在另一个矩形内部 40 | * @param otherRect {Rect} 41 | */ 42 | public isIn(otherRect: Rect): boolean { 43 | return ( 44 | this.x >= otherRect.x && 45 | this.y >= otherRect.y && 46 | this.x + this.width <= otherRect.x + otherRect.width && 47 | this.y + this.height <= otherRect.y + otherRect.height 48 | ); 49 | } 50 | } 51 | 52 | export default Rect; 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 zoolsher 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 | -------------------------------------------------------------------------------- /.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 (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | .vscode/ 64 | 65 | esm 66 | 67 | lib 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bin-packing-core", 3 | "version": "0.2.0-beta02", 4 | "description": "image packer based on genetic & max-rect algorithm", 5 | "main": "lib/index.js", 6 | "module": "esm/index.js", 7 | "files": [ 8 | "lib", 9 | "esm" 10 | ], 11 | "scripts": { 12 | "dev": "tsc -p ./tsconfig.json --watch ", 13 | "test": "mocha -r ts-node/register test/**/**.ts", 14 | "build": "npm run build:es5 && npm run build:es6", 15 | "build:es5": "tsc -p ./tsconfig.json -d", 16 | "build:es6": "tsc -p ./tsconfig.esm.json -d", 17 | "lint": "tslint --project ./tsconfig.json", 18 | "lint:fix": "tslint --fix --project ./tsconfig.json", 19 | "prepublish": "tnpm run build" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git+git@github.com:ant-tinyjs/bin-packing-core.git" 24 | }, 25 | "author": "", 26 | "license": "ISC", 27 | "bugs": { 28 | "url": "https://github.com/ant-tinyjs/bin-packing-core/issues" 29 | }, 30 | "homepage": "https://github.com/ant-tinyjs/bin-packing-core#readme", 31 | "dependencies": {}, 32 | "devDependencies": { 33 | "@types/chai": "^4.1.3", 34 | "@types/mocha": "^5.2.2", 35 | "@types/node": "^10.3.2", 36 | "chai": "^4.1.2", 37 | "mocha": "^5.2.0", 38 | "tslint": "^5.8.0", 39 | "ts-node": "^6.1.1", 40 | "typescript": "^2.9.2" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/charts.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Document 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- 1 | //tslint:disable 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import genetic from './../src/genetic'; 5 | import MaxRectBinPack, { FindPosition } from './../src/max-rect-bin-pack'; 6 | import Rect from './../src/rect'; 7 | import { Search } from './../src/search'; 8 | 9 | const rects: Rect[] = []; 10 | 11 | // const r = require('./rects.json'); 12 | 13 | // r.forEach(($: any) => { 14 | // var rect = new Rect(); 15 | // rect.width = $.width; 16 | // rect.height = $.height; 17 | // rect.info = {}; 18 | // rect.info.width = $.width; 19 | // rect.info.height = $.height; 20 | // rects.push(rect); 21 | // }); 22 | 23 | var rect1 = new Rect(); 24 | rect1.width = 485; 25 | rect1.height = 869; 26 | rects.push(rect1); 27 | 28 | var rect2 = new Rect(); 29 | rect2.width = 96; 30 | rect2.height = 104; 31 | rects.push(rect2); 32 | 33 | // function getRects() { 34 | // return rects.map($ => $.clone()); 35 | // } 36 | 37 | // let maxWidth = rects.reduce(($, $$) => $ + $$.width, 0); 38 | // let maxHeight = rects.reduce(($, $$) => $ + $$.height, 0); 39 | // debugger; 40 | // const step = 1; 41 | // const result = []; 42 | // for (let currentWidth = 0; currentWidth <= maxWidth; currentWidth += step) { 43 | // for ( 44 | // let currentHeight = 0; 45 | // currentHeight <= maxHeight; 46 | // currentHeight += step 47 | // ) { 48 | // const packer = new MaxRectBinPack(currentWidth, currentHeight, false); 49 | // const inserts = packer.insertRects(getRects(), 0); 50 | // if (inserts.length === 2) { 51 | // console.log(currentWidth, currentHeight); 52 | // result.push({ 53 | // width: currentWidth, 54 | // height: currentHeight, 55 | // op: packer.occupancy(), 56 | // }); 57 | // } 58 | // } 59 | // } 60 | // fs.writeFileSync( 61 | // path.join(__dirname, 'data.js'), 62 | // `var data = ${JSON.stringify(result)}`, 63 | // { 64 | // flag: 'w+', 65 | // }, 66 | // ); 67 | // debugger; 68 | const serach = new Search(rects, false, 10, 0, Infinity); 69 | const bestNode = serach.search(); 70 | 71 | console.log(bestNode); 72 | const packer = new MaxRectBinPack(bestNode.x, bestNode.y, false); 73 | 74 | let rectslength = rects.length; 75 | console.log(rectslength); 76 | const result = packer.insertRects(rects, FindPosition.AreaFit); 77 | console.log(JSON.stringify(result)); 78 | console.log(packer.occupancy()); 79 | console.log(result.length); 80 | console.log(bestNode.x * bestNode.y); 81 | console.log(result.length === rectslength); 82 | fs.writeFileSync( 83 | path.join(__dirname, 'data.js'), 84 | `var data = ${JSON.stringify(result)}`, 85 | { 86 | flag: 'w+', 87 | }, 88 | ); 89 | -------------------------------------------------------------------------------- /src/search.ts: -------------------------------------------------------------------------------- 1 | import MaxRectBinPack, { FindPosition } from './max-rect-bin-pack'; 2 | import Rect from './rect'; 3 | 4 | interface IPoint { 5 | x: number; 6 | y: number; 7 | } 8 | 9 | export class Search { 10 | private rects: Rect[] = []; 11 | private findPosition: FindPosition; 12 | private allowRotate: boolean; 13 | private step: number; 14 | private rate: number; 15 | 16 | private totalSquares: number = 0; 17 | private maxHeight: number = -1; 18 | private maxWidth: number = -1; 19 | 20 | get minHeight() { 21 | return this.totalSquares / this.maxWidth; 22 | } 23 | get minWidth() { 24 | return this.totalSquares / this.maxHeight; 25 | } 26 | /** 27 | * 初始化 28 | * @param rects 要插入的矩形数组 29 | * @param allowRotate 是否旋转 30 | * @param step 搜索步长 建议10 31 | * @param findPosition FindPostion 策略 32 | * @param rate 大于一的比率 等于1不可以的 33 | */ 34 | constructor(rects: Rect[], allowRotate: boolean, step: number, findPosition: FindPosition, rate: number) { 35 | this.rects = rects; 36 | this.allowRotate = allowRotate === true; 37 | this.step = step ? step : 1; 38 | this.findPosition = findPosition; 39 | if (rate <= 1) { 40 | throw new Error('rate should be grater than 1, but get ' + rate); 41 | } 42 | this.rate = rate; 43 | this.totalSquares = this.rects.reduce((i, v) => { 44 | return i + v.height * v.width; 45 | }, 0); 46 | this.maxWidth = this.rects.reduce((i, v) => { 47 | return i + v.width; 48 | }, this.step + 1); // 防止刚好踩到临界点情况 49 | this.maxHeight = this.rects.reduce((i, v) => { 50 | return i + v.height; 51 | }, this.step + 1); // 防止刚好踩到临界点情况 52 | } 53 | public search(): IPoint { 54 | const bestResult = { 55 | height: 0, 56 | op: 0, 57 | width: 0, 58 | }; 59 | for (let searchWidth = this.minWidth; searchWidth <= this.maxWidth; searchWidth += this.step) { 60 | const [height, op] = this.bestHeight(searchWidth); 61 | if (op > bestResult.op) { 62 | bestResult.width = searchWidth; 63 | bestResult.height = height; 64 | bestResult.op = op; 65 | } 66 | } 67 | return { x: bestResult.width, y: bestResult.height }; 68 | } 69 | public bestHeight(width: number): [number, number] { 70 | let left = Math.max(this.minHeight, width / this.rate); 71 | let right = Math.min(this.maxHeight, width * this.rate); 72 | let bestResult = 0; 73 | let mid = 0; 74 | let bestHeight = 0; 75 | while (right - left >= this.step) { 76 | mid = Math.ceil((right + left) / 2); 77 | const [result, op] = this.getInsertResult(width, mid); 78 | const isSuccess = result.length === this.rects.length; 79 | if (isSuccess) { 80 | if (op > bestResult) { 81 | bestResult = op; 82 | bestHeight = mid; 83 | } 84 | right = mid; 85 | } else { 86 | left = mid; 87 | } 88 | } 89 | return [bestHeight, bestResult]; 90 | } 91 | private getInsertResult(width: number, height: number): [Rect[], number] { 92 | const binpacker = new MaxRectBinPack(width, height, this.allowRotate); 93 | const result = binpacker.insertRects(this.getRects(), this.findPosition); 94 | return [result, binpacker.occupancy()]; 95 | } 96 | private getRects() { 97 | return this.rects.map($ => { 98 | return $.clone(); 99 | }); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 基于人工智能的雪碧图拼接算法 2 | 3 | **keywords:** `bin-pack` `image-pack` `max-rect` `genetic` `typescript` `javascript` `node.js` `image-resource` `ai` 4 | 5 | ## 应用场景 6 | 7 | 前端资源中经常需要将所有的小图合成大图,如何将合成大图的图片压缩到最小,就是本算法要解决的问题。 8 | 9 | 使用本算法可以有效减小图片资源的`解析时长`和`内存占用`。 10 | 11 | 基于目前亿级PV项目验证,可以减小10%~30%的内存占用。 12 | 13 | ## 算法 14 | 15 | **算法分为两层** 16 | 17 | 1. 底层是基于 `max-rect-bin-pack` 的 `Rectangle Bin Pack` 算法 18 | 2. 上层是基于 遗传算法 的最优化的搜索算法。 // 更新了二分法 19 | 20 | **FYI:** 底层算法分为`在线`和`离线`两种模式: 离线算法有一定的优化方案,所以离线算法效果更好。上层的遗传算法强制调用了离线算法。 21 | 22 | **算法的复杂度** 23 | 24 | 1. 底层 `max-rect-bin-pack` 算法 25 | 26 | ![max-rect-time](./image-resource/max-rect-time.png) 27 | 28 | 在线算法虽然效率更高,但是在线算法结果没有离线算法结果好。 29 | 30 | 2. 上层 遗传算法 31 | 32 | 遗传算法的时间空间复杂参考[paper](https://pdfs.semanticscholar.org/18e1/03c600134d55a4ef084f11bee045b8505cf7.pdf?_ga=2.45871975.213758763.1529380129-1889820781.1529380129)。 33 | 34 | 大致上可以认为和种群中孩子个数和生态个数成正相关。 35 | 36 | ## API & demo 37 | 38 | 1. FindPosition 39 | 40 | 寻找矩形位置的五种策略。 41 | 42 | ```typescript 43 | enum FindPosition { 44 | ShortSideFit = 0, 45 | BottomLeft, 46 | ContactPoint, 47 | LongSideFit, 48 | AreaFit, 49 | } 50 | ``` 51 | 52 | 2. Rect 53 | 54 | ```javascript 55 | const rect = new Rect(); 56 | rect.width = 90; // 宽度 57 | rect.height = 90; // 高度 58 | rect.x;// x 坐标 59 | rect.y;// y 坐标 60 | rect.isRotated; // 是否被旋转了 61 | rect.info; // 开发者自行写入一些属性,可以在返回值中拿到(基于浅拷贝) 62 | ``` 63 | 64 | 3. `max-rect-bin-pack` 调用 65 | 66 | ```javascript 67 | import { MaxRectBinPack, Rect } from 'name'; 68 | const width = 200; 69 | const height = 200; 70 | const allowRotate = true; 71 | const packer = MaxRectBinPack(width, height, allowRotate); 72 | 73 | const findPosition = 0; // 参照第一条 74 | 75 | // 在线算法 尽量不要使用 76 | for(const rect of rects){ 77 | const result = packer.insert(rect.width, rect.height, findPosition); 78 | if(result.width!==rect.width||result.height!==rect.height){ // 插入失败返回一个宽高为0的Rect 79 | throw new Error('insert failed'); 80 | }else{ 81 | console.log(result.x, result.y, result.widht, result.height); // 插入成功 82 | } 83 | } 84 | 85 | //离线算法 推荐使用 86 | const rects: Rect[] = []; 87 | const rectsCopy = rects.map($=>$.clone());// js引用传递,这里直接操纵了传入的数组,所以传入一份clone的。 88 | const result = packer.insertRects(rectsCopy, findPosition); 89 | if(result.length!==rects.length){ // 插入失败,因为容器大小不足 90 | throw new Error('insert failed'); 91 | }else{ //插入成功 92 | result.forEach(rect => { 93 | console.log(rect.x, rect.y, rect.width, rect.height); 94 | }); 95 | } 96 | ``` 97 | 98 | 4. `genetic` 遗传算法 调用 99 | 100 | ```typescript 101 | import { MaxRectBinPack, Rect, genetic } from ''; 102 | const rects: Rect[] = []; 103 | 104 | const findPosition = 0; // 参照第一条 105 | 106 | const bestSize = genetic(rects, { // 遗传算法确定最优策略 107 | findPosition: findPosition, 108 | lifeTimes: 50, // 代数 109 | liveRate: 0.5, // 存活率 110 | size: 50, // 每一代孩子个数 111 | allowRotate: false,// 不支持旋转 112 | }); 113 | 114 | const width = bestSize.x; 115 | const height = bestSize.y; 116 | const packer = new MaxRectBinPack(width, height, false);//不支持旋转 117 | const result = packer.insertRects(rects, findPosition); 118 | console.log(result.length === /* rects.length */); 119 | ``` 120 | 121 | 5. `搜索算法` 122 | 123 | ```typescript 124 | /** 125 | * 初始化 126 | * @param rects 要插入的矩形数组 127 | * @param allowRotate 是否旋转 128 | * @param step 搜索步长 建议10 129 | * @param findPosition FindPostion 策略 130 | * @param rate 大于一的比率 等于1不可以的 131 | */ 132 | const serach = new Search(rects, false, 10, 0, 1.1); 133 | const bestNode = serach.search(); 134 | 135 | console.log(bestNode); 136 | const packer = new MaxRectBinPack(bestNode.x, bestNode.y, false); 137 | const result = packer.insertRects(rects, FindPosition.AreaFit); 138 | ``` 139 | 140 | ## TODO 141 | 142 | - [ ] 基因编码策略升级 143 | - [ ] 返回结果优化 144 | - [X] 提升算法稳定性,回归点有多个的时候会出现回归到次点的情况 145 | -------------------------------------------------------------------------------- /src/genetic.ts: -------------------------------------------------------------------------------- 1 | import MaxRectBinPack, { FindPosition } from './max-rect-bin-pack'; 2 | import Rect from './rect'; 3 | 4 | export interface IGeneticOptions { 5 | lifeTimes: number; 6 | size: number; 7 | findPosition: FindPosition; 8 | liveRate: number; 9 | allowRotate: boolean; 10 | } 11 | 12 | interface IPoint { 13 | x: number; 14 | y: number; 15 | } 16 | 17 | interface ICalResult { 18 | dot: IPoint; 19 | fitAll: boolean; 20 | occupancy: number; 21 | } 22 | 23 | export class Genetic { 24 | private rects: Rect[] = []; 25 | private lifeTimes: number; 26 | private size: number; 27 | private findPosition: FindPosition; 28 | private liveRate: number; 29 | private allowRotate: boolean; 30 | 31 | private totalSquares: number = 0; 32 | private maxHeight: number = -1; 33 | private maxWidth: number = -1; 34 | 35 | private randomDots: IPoint[] = []; 36 | private bestDot: IPoint; 37 | get minHeight() { 38 | return this.totalSquares / this.maxWidth; 39 | } 40 | get minWidth() { 41 | return this.totalSquares / this.maxHeight; 42 | } 43 | constructor(rects: Rect[], options?: IGeneticOptions) { 44 | if (!options) { 45 | options = {} as IGeneticOptions; 46 | } 47 | this.rects = rects; 48 | this.size = options.size < 20 ? 20 : options.size; 49 | this.lifeTimes = options.lifeTimes || 8; 50 | this.liveRate = options.liveRate || 0.5; 51 | if (this.liveRate < 0 || this.liveRate > 1) { 52 | this.liveRate = 0.5; 53 | } 54 | this.findPosition = options.findPosition; 55 | this.allowRotate = options.allowRotate === true ? true : false; 56 | } 57 | public calc(): IPoint { 58 | this.open(); 59 | this.work(); 60 | return this.close(); 61 | } 62 | private open() { 63 | // 二维空间内寻找边界 64 | for (const rect of this.rects) { 65 | this.totalSquares = rect.height * rect.width + this.totalSquares; 66 | this.maxHeight = rect.height + this.maxHeight; 67 | this.maxWidth = rect.width + this.maxWidth; 68 | } 69 | // 二维空间内生成随机点 虽然无法保证分布是随机分布,但是对遗传算法来说无所谓了。 70 | for (let i = 0; i < this.size; i++) { 71 | const randomHeight = this.maxHeight * Math.random() + this.minHeight; 72 | const randomWidth = this.maxWidth * Math.random() + this.minWidth; 73 | this.randomDots.push({ 74 | x: randomWidth, 75 | y: randomHeight, 76 | }); 77 | } 78 | } 79 | private work() { 80 | while (this.lifeTimes--) { 81 | const generation: ICalResult[] = []; 82 | for (const dot of this.randomDots) { 83 | // 生活 84 | const binPack = new MaxRectBinPack(dot.x, dot.y, this.allowRotate); 85 | const clonedRects = this.getRects(); 86 | const result = binPack.insertRects(clonedRects, this.findPosition); 87 | generation.push({ 88 | dot, 89 | fitAll: result.length === this.rects.length, 90 | occupancy: binPack.occupancy(), 91 | }); 92 | } 93 | // 淘汰 94 | generation.sort((geneticA, geneticB) => { 95 | if (geneticB.fitAll && geneticA.fitAll) { 96 | return geneticB.occupancy - geneticA.occupancy; 97 | } else if (geneticB.fitAll) { 98 | return 1; 99 | } else if (geneticA.fitAll) { 100 | return -1; 101 | } else { 102 | return geneticB.occupancy - geneticA.occupancy; 103 | } 104 | }); 105 | this.bestDot = generation[0].dot; 106 | // 后置位淘汰有利于数据优化 107 | if (generation.length > this.size) { 108 | generation.splice(this.size, generation.length - this.size); 109 | } 110 | const killerStart = Math.ceil(this.liveRate * generation.length); 111 | generation.splice(killerStart, generation.length - killerStart); 112 | for (let i = generation.length - 1; i > 0; i--) { 113 | if (!generation[i].fitAll) { 114 | generation.splice(i, 1); 115 | } 116 | } 117 | this.randomDots = []; 118 | // 新生 119 | if (generation.length === 0 || generation.length === 1) { 120 | // 如果团灭了 或者 无法继续交配 121 | for (let i = 0; i < this.size; i++) { 122 | const randomHeight = this.maxHeight * Math.random() + this.minHeight; 123 | const randomWidth = this.maxWidth * Math.random() + this.minWidth; 124 | this.randomDots.push({ 125 | x: randomWidth, 126 | y: randomHeight, 127 | }); 128 | } 129 | } else { 130 | // 非随机交配 131 | for (let i = 0; i < generation.length; i++) { 132 | this.randomDots.push(generation[i].dot); 133 | for (let j = i + 1; j < generation.length; j++) { 134 | const startPoint = generation[i].dot; 135 | const endPoint = generation[j].dot; 136 | const [sonX, daughterX] = this.cross(startPoint.x, endPoint.x); 137 | const [sonY, daughterY] = this.cross(startPoint.y, endPoint.y); 138 | this.randomDots.push({ 139 | x: sonX, 140 | y: sonY, 141 | }); 142 | this.randomDots.push({ 143 | x: daughterX, 144 | y: daughterY, 145 | }); 146 | } 147 | } 148 | // 基因突变 149 | for (let i = 0; i < 20; i++) { 150 | const randomHeight = this.maxHeight * Math.random() + this.minHeight; 151 | const randomWidth = this.maxWidth * Math.random() + this.minWidth; 152 | this.randomDots.push({ 153 | x: randomWidth, 154 | y: randomHeight, 155 | }); 156 | } 157 | } 158 | } 159 | } 160 | private close() { 161 | return this.bestDot; 162 | } 163 | private getRects(): Rect[] { 164 | return this.rects.map(i => i.clone()); // tslint:disable-line arrow-parens 165 | } 166 | private cross(x: number, y: number) { 167 | const lerp = (a: number, b: number) => { 168 | return a + (b - a); 169 | }; 170 | const formX = parseInt(x * 100 + '', 10); 171 | const formY = parseInt(y * 100 + '', 10); 172 | const binX = formX 173 | .toString(2) 174 | .split('') 175 | .map($ => parseInt($, 10)); // tslint:disable-line 176 | const binY = formY 177 | .toString(2) 178 | .split('') 179 | .map($ => parseInt($, 10)); // tslint:disable-line 180 | 181 | const son = [].concat(binX); 182 | const daughter = [].concat(binY); 183 | const i = Math.floor(Math.random() * binY.length); 184 | son[i] = lerp(binX[i], binY[i]); 185 | daughter[i] = lerp(binY[i], binX[i]); 186 | const sonvalue = parseInt(son.join(''), 2) / 100; 187 | const daughtervalue = parseInt(daughter.join(''), 2) / 100; 188 | return [sonvalue, daughtervalue]; 189 | } 190 | } 191 | 192 | export default function(rect: Rect[], options: IGeneticOptions) { 193 | const g = new Genetic(rect, options); 194 | return g.calc(); 195 | } 196 | -------------------------------------------------------------------------------- /test/rects1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "x": 0, 4 | "y": 0, 5 | "width": 387, 6 | "height": 360, 7 | "isRotated": false, 8 | "info": { "name": "tileset-ornaments-fCap-bear" } 9 | }, 10 | { 11 | "x": 0, 12 | "y": 0, 13 | "width": 331, 14 | "height": 257, 15 | "isRotated": false, 16 | "info": { "name": "tileset-ornaments-fCap-cook" } 17 | }, 18 | { 19 | "x": 0, 20 | "y": 0, 21 | "width": 288, 22 | "height": 222, 23 | "isRotated": false, 24 | "info": { "name": "tileset-ornaments-fCap-huabei" } 25 | }, 26 | { 27 | "x": 0, 28 | "y": 0, 29 | "width": 360, 30 | "height": 225, 31 | "isRotated": false, 32 | "info": { "name": "tileset-ornaments-fCap-lady" } 33 | }, 34 | { 35 | "x": 0, 36 | "y": 0, 37 | "width": 370, 38 | "height": 252, 39 | "isRotated": false, 40 | "info": { "name": "tileset-ornaments-fCap-lili" } 41 | }, 42 | { 43 | "x": 0, 44 | "y": 0, 45 | "width": 356, 46 | "height": 231, 47 | "isRotated": false, 48 | "info": { "name": "tileset-ornaments-fCap-mike" } 49 | }, 50 | { 51 | "x": 0, 52 | "y": 0, 53 | "width": 352, 54 | "height": 211, 55 | "isRotated": false, 56 | "info": { "name": "tileset-ornaments-fCap-rich" } 57 | }, 58 | { 59 | "x": 0, 60 | "y": 0, 61 | "width": 315, 62 | "height": 260, 63 | "isRotated": false, 64 | "info": { "name": "tileset-ornaments-fCap-spring-festival" } 65 | }, 66 | { 67 | "x": 0, 68 | "y": 0, 69 | "width": 307, 70 | "height": 258, 71 | "isRotated": false, 72 | "info": { "name": "tileset-ornaments-fCap-wild" } 73 | }, 74 | { 75 | "x": 0, 76 | "y": 0, 77 | "width": 369, 78 | "height": 267, 79 | "isRotated": false, 80 | "info": { "name": "tileset-ornaments-fCoat-bear" } 81 | }, 82 | { 83 | "x": 0, 84 | "y": 0, 85 | "width": 379, 86 | "height": 285, 87 | "isRotated": false, 88 | "info": { "name": "tileset-ornaments-fCoat-cook" } 89 | }, 90 | { 91 | "x": 0, 92 | "y": 0, 93 | "width": 381, 94 | "height": 277, 95 | "isRotated": false, 96 | "info": { "name": "tileset-ornaments-fCoat-huabei" } 97 | }, 98 | { 99 | "x": 0, 100 | "y": 0, 101 | "width": 381, 102 | "height": 293, 103 | "isRotated": false, 104 | "info": { "name": "tileset-ornaments-fCoat-lady" } 105 | }, 106 | { 107 | "x": 0, 108 | "y": 0, 109 | "width": 382, 110 | "height": 264, 111 | "isRotated": false, 112 | "info": { "name": "tileset-ornaments-fCoat-lili" } 113 | }, 114 | { 115 | "x": 0, 116 | "y": 0, 117 | "width": 400, 118 | "height": 274, 119 | "isRotated": false, 120 | "info": { "name": "tileset-ornaments-fCoat-mike" } 121 | }, 122 | { 123 | "x": 0, 124 | "y": 0, 125 | "width": 375, 126 | "height": 229, 127 | "isRotated": false, 128 | "info": { "name": "tileset-ornaments-fCoat-rich" } 129 | }, 130 | { 131 | "x": 0, 132 | "y": 0, 133 | "width": 381, 134 | "height": 263, 135 | "isRotated": false, 136 | "info": { "name": "tileset-ornaments-fCoat-wild" } 137 | }, 138 | { 139 | "x": 0, 140 | "y": 0, 141 | "width": 252, 142 | "height": 169, 143 | "isRotated": false, 144 | "info": { "name": "tileset-ornaments-fGlass-bear" } 145 | }, 146 | { 147 | "x": 0, 148 | "y": 0, 149 | "width": 217, 150 | "height": 235, 151 | "isRotated": false, 152 | "info": { "name": "tileset-ornaments-fGlass-mike" } 153 | }, 154 | { 155 | "x": 0, 156 | "y": 0, 157 | "width": 298, 158 | "height": 187, 159 | "isRotated": false, 160 | "info": { "name": "tileset-ornaments-fGlass-rich" } 161 | }, 162 | { 163 | "x": 0, 164 | "y": 0, 165 | "width": 342, 166 | "height": 209, 167 | "isRotated": false, 168 | "info": { "name": "tileset-ornaments-fGlass-wild" } 169 | }, 170 | { 171 | "x": 0, 172 | "y": 0, 173 | "width": 386, 174 | "height": 242, 175 | "isRotated": false, 176 | "info": { "name": "tileset-ornaments-fNecklace-cook" } 177 | }, 178 | { 179 | "x": 0, 180 | "y": 0, 181 | "width": 364, 182 | "height": 272, 183 | "isRotated": false, 184 | "info": { "name": "tileset-ornaments-fNecklace-lili" } 185 | }, 186 | { 187 | "x": 0, 188 | "y": 0, 189 | "width": 361, 190 | "height": 296, 191 | "isRotated": false, 192 | "info": { "name": "tileset-ornaments-fNecklace-rich" } 193 | }, 194 | { 195 | "x": 0, 196 | "y": 0, 197 | "width": 406, 198 | "height": 255, 199 | "isRotated": false, 200 | "info": { "name": "tileset-ornaments-fNecklace-spring-festival" } 201 | }, 202 | { 203 | "x": 0, 204 | "y": 0, 205 | "width": 130, 206 | "height": 131, 207 | "isRotated": false, 208 | "info": { "name": "tileset-ornaments-fSlag-apple" } 209 | }, 210 | { 211 | "x": 0, 212 | "y": 0, 213 | "width": 130, 214 | "height": 131, 215 | "isRotated": false, 216 | "info": { "name": "tileset-ornaments-fSlag-bunch" } 217 | }, 218 | { 219 | "x": 0, 220 | "y": 0, 221 | "width": 130, 222 | "height": 131, 223 | "isRotated": false, 224 | "info": { "name": "tileset-ornaments-fSlag-donut" } 225 | }, 226 | { 227 | "x": 0, 228 | "y": 0, 229 | "width": 130, 230 | "height": 131, 231 | "isRotated": false, 232 | "info": { "name": "tileset-ornaments-fSlag-grape" } 233 | }, 234 | { 235 | "x": 0, 236 | "y": 0, 237 | "width": 130, 238 | "height": 131, 239 | "isRotated": false, 240 | "info": { "name": "tileset-ornaments-fSlag-icecream" } 241 | }, 242 | { 243 | "x": 0, 244 | "y": 0, 245 | "width": 339, 246 | "height": 362, 247 | "isRotated": false, 248 | "info": { "name": "tileset-ornaments-sCap-bear" } 249 | }, 250 | { 251 | "x": 0, 252 | "y": 0, 253 | "width": 323, 254 | "height": 235, 255 | "isRotated": false, 256 | "info": { "name": "tileset-ornaments-sCap-cook" } 257 | }, 258 | { 259 | "x": 0, 260 | "y": 0, 261 | "width": 331, 262 | "height": 227, 263 | "isRotated": false, 264 | "info": { "name": "tileset-ornaments-sCap-huabei" } 265 | }, 266 | { 267 | "x": 0, 268 | "y": 0, 269 | "width": 359, 270 | "height": 227, 271 | "isRotated": false, 272 | "info": { "name": "tileset-ornaments-sCap-lady" } 273 | }, 274 | { 275 | "x": 0, 276 | "y": 0, 277 | "width": 381, 278 | "height": 235, 279 | "isRotated": false, 280 | "info": { "name": "tileset-ornaments-sCap-lili" } 281 | }, 282 | { 283 | "x": 0, 284 | "y": 0, 285 | "width": 327, 286 | "height": 227, 287 | "isRotated": false, 288 | "info": { "name": "tileset-ornaments-sCap-mike" } 289 | }, 290 | { 291 | "x": 0, 292 | "y": 0, 293 | "width": 352, 294 | "height": 214, 295 | "isRotated": false, 296 | "info": { "name": "tileset-ornaments-sCap-rich" } 297 | }, 298 | { 299 | "x": 0, 300 | "y": 0, 301 | "width": 315, 302 | "height": 256, 303 | "isRotated": false, 304 | "info": { "name": "tileset-ornaments-sCap-spring-festival" } 305 | }, 306 | { 307 | "x": 0, 308 | "y": 0, 309 | "width": 305, 310 | "height": 264, 311 | "isRotated": false, 312 | "info": { "name": "tileset-ornaments-sCap-wild" } 313 | }, 314 | { 315 | "x": 0, 316 | "y": 0, 317 | "width": 387, 318 | "height": 266, 319 | "isRotated": false, 320 | "info": { "name": "tileset-ornaments-sCoat-bear" } 321 | }, 322 | { 323 | "x": 0, 324 | "y": 0, 325 | "width": 385, 326 | "height": 282, 327 | "isRotated": false, 328 | "info": { "name": "tileset-ornaments-sCoat-cook" } 329 | }, 330 | { 331 | "x": 0, 332 | "y": 0, 333 | "width": 384, 334 | "height": 252, 335 | "isRotated": false, 336 | "info": { "name": "tileset-ornaments-sCoat-huabei" } 337 | }, 338 | { 339 | "x": 0, 340 | "y": 0, 341 | "width": 394, 342 | "height": 270, 343 | "isRotated": false, 344 | "info": { "name": "tileset-ornaments-sCoat-lady" } 345 | }, 346 | { 347 | "x": 0, 348 | "y": 0, 349 | "width": 386, 350 | "height": 254, 351 | "isRotated": false, 352 | "info": { "name": "tileset-ornaments-sCoat-lili" } 353 | }, 354 | { 355 | "x": 0, 356 | "y": 0, 357 | "width": 386, 358 | "height": 250, 359 | "isRotated": false, 360 | "info": { "name": "tileset-ornaments-sCoat-mike" } 361 | }, 362 | { 363 | "x": 0, 364 | "y": 0, 365 | "width": 382, 366 | "height": 227, 367 | "isRotated": false, 368 | "info": { "name": "tileset-ornaments-sCoat-rich" } 369 | }, 370 | { 371 | "x": 0, 372 | "y": 0, 373 | "width": 397, 374 | "height": 262, 375 | "isRotated": false, 376 | "info": { "name": "tileset-ornaments-sCoat-wild" } 377 | }, 378 | { 379 | "x": 0, 380 | "y": 0, 381 | "width": 428, 382 | "height": 508, 383 | "isRotated": false, 384 | "info": { "name": "tileset-ornaments-sets-bear" } 385 | }, 386 | { 387 | "x": 0, 388 | "y": 0, 389 | "width": 422, 390 | "height": 523, 391 | "isRotated": false, 392 | "info": { "name": "tileset-ornaments-sets-cook" } 393 | }, 394 | { 395 | "x": 0, 396 | "y": 0, 397 | "width": 423, 398 | "height": 498, 399 | "isRotated": false, 400 | "info": { "name": "tileset-ornaments-sets-huabei" } 401 | }, 402 | { 403 | "x": 0, 404 | "y": 0, 405 | "width": 422, 406 | "height": 489, 407 | "isRotated": false, 408 | "info": { "name": "tileset-ornaments-sets-lady" } 409 | }, 410 | { 411 | "x": 0, 412 | "y": 0, 413 | "width": 430, 414 | "height": 493, 415 | "isRotated": false, 416 | "info": { "name": "tileset-ornaments-sets-lili" } 417 | }, 418 | { 419 | "x": 0, 420 | "y": 0, 421 | "width": 423, 422 | "height": 490, 423 | "isRotated": false, 424 | "info": { "name": "tileset-ornaments-sets-mike" } 425 | }, 426 | { 427 | "x": 0, 428 | "y": 0, 429 | "width": 422, 430 | "height": 497, 431 | "isRotated": false, 432 | "info": { "name": "tileset-ornaments-sets-rich" } 433 | }, 434 | { 435 | "x": 0, 436 | "y": 0, 437 | "width": 427, 438 | "height": 517, 439 | "isRotated": false, 440 | "info": { "name": "tileset-ornaments-sets-spring-festival" } 441 | }, 442 | { 443 | "x": 0, 444 | "y": 0, 445 | "width": 428, 446 | "height": 532, 447 | "isRotated": false, 448 | "info": { "name": "tileset-ornaments-sets-wild" } 449 | }, 450 | { 451 | "x": 0, 452 | "y": 0, 453 | "width": 232, 454 | "height": 175, 455 | "isRotated": false, 456 | "info": { "name": "tileset-ornaments-sGlass-rich" } 457 | }, 458 | { 459 | "x": 0, 460 | "y": 0, 461 | "width": 206, 462 | "height": 214, 463 | "isRotated": false, 464 | "info": { "name": "tileset-ornaments-snacks-apple1" } 465 | }, 466 | { 467 | "x": 0, 468 | "y": 0, 469 | "width": 206, 470 | "height": 214, 471 | "isRotated": false, 472 | "info": { "name": "tileset-ornaments-snacks-apple2" } 473 | }, 474 | { 475 | "x": 0, 476 | "y": 0, 477 | "width": 206, 478 | "height": 214, 479 | "isRotated": false, 480 | "info": { "name": "tileset-ornaments-snacks-apple3" } 481 | }, 482 | { 483 | "x": 0, 484 | "y": 0, 485 | "width": 190, 486 | "height": 248, 487 | "isRotated": false, 488 | "info": { "name": "tileset-ornaments-snacks-bunch1" } 489 | }, 490 | { 491 | "x": 0, 492 | "y": 0, 493 | "width": 190, 494 | "height": 248, 495 | "isRotated": false, 496 | "info": { "name": "tileset-ornaments-snacks-bunch2" } 497 | }, 498 | { 499 | "x": 0, 500 | "y": 0, 501 | "width": 190, 502 | "height": 248, 503 | "isRotated": false, 504 | "info": { "name": "tileset-ornaments-snacks-bunch3" } 505 | }, 506 | { 507 | "x": 0, 508 | "y": 0, 509 | "width": 224, 510 | "height": 222, 511 | "isRotated": false, 512 | "info": { "name": "tileset-ornaments-snacks-donut1" } 513 | }, 514 | { 515 | "x": 0, 516 | "y": 0, 517 | "width": 224, 518 | "height": 222, 519 | "isRotated": false, 520 | "info": { "name": "tileset-ornaments-snacks-donut2" } 521 | }, 522 | { 523 | "x": 0, 524 | "y": 0, 525 | "width": 224, 526 | "height": 222, 527 | "isRotated": false, 528 | "info": { "name": "tileset-ornaments-snacks-donut3" } 529 | }, 530 | { 531 | "x": 0, 532 | "y": 0, 533 | "width": 206, 534 | "height": 217, 535 | "isRotated": false, 536 | "info": { "name": "tileset-ornaments-snacks-grape1" } 537 | }, 538 | { 539 | "x": 0, 540 | "y": 0, 541 | "width": 206, 542 | "height": 217, 543 | "isRotated": false, 544 | "info": { "name": "tileset-ornaments-snacks-grape2" } 545 | }, 546 | { 547 | "x": 0, 548 | "y": 0, 549 | "width": 206, 550 | "height": 217, 551 | "isRotated": false, 552 | "info": { "name": "tileset-ornaments-snacks-grape3" } 553 | }, 554 | { 555 | "x": 0, 556 | "y": 0, 557 | "width": 206, 558 | "height": 237, 559 | "isRotated": false, 560 | "info": { "name": "tileset-ornaments-snacks-icecream1" } 561 | }, 562 | { 563 | "x": 0, 564 | "y": 0, 565 | "width": 206, 566 | "height": 237, 567 | "isRotated": false, 568 | "info": { "name": "tileset-ornaments-snacks-icecream2" } 569 | }, 570 | { 571 | "x": 0, 572 | "y": 0, 573 | "width": 206, 574 | "height": 237, 575 | "isRotated": false, 576 | "info": { "name": "tileset-ornaments-snacks-icecream3" } 577 | }, 578 | { 579 | "x": 0, 580 | "y": 0, 581 | "width": 381, 582 | "height": 226, 583 | "isRotated": false, 584 | "info": { "name": "tileset-ornaments-sNecklace-cook" } 585 | }, 586 | { 587 | "x": 0, 588 | "y": 0, 589 | "width": 356, 590 | "height": 289, 591 | "isRotated": false, 592 | "info": { "name": "tileset-ornaments-sNecklace-rich" } 593 | }, 594 | { 595 | "x": 0, 596 | "y": 0, 597 | "width": 397, 598 | "height": 211, 599 | "isRotated": false, 600 | "info": { "name": "tileset-ornaments-sNecklace-spring-festival" } 601 | } 602 | ] 603 | -------------------------------------------------------------------------------- /src/max-rect-bin-pack.ts: -------------------------------------------------------------------------------- 1 | import Rect from './rect'; 2 | 3 | export enum FindPosition { 4 | ShortSideFit = 0, 5 | BottomLeft, 6 | ContactPoint, 7 | LongSideFit, 8 | AreaFit, 9 | } 10 | 11 | // 强制引用传递套个壳子 12 | interface IScoreCounter { 13 | value: number; 14 | } 15 | 16 | class MaxRectBinPack { 17 | private containerHeight: number; 18 | private containerWidth: number; 19 | private allowRotate: boolean; 20 | private freeRects: Rect[] = []; 21 | private usedRects: Rect[] = []; 22 | /** 23 | * 构建方程 24 | * @param width {number} 画板宽度 25 | * @param height {number} 画板高度 26 | * @param allowRotate {boolean} 允许旋转 27 | */ 28 | constructor(width: number, height: number, allowRotate?: boolean) { 29 | this.containerHeight = height; 30 | this.containerWidth = width; 31 | this.allowRotate = allowRotate === true; 32 | 33 | const rect = new Rect(); 34 | rect.x = 0; 35 | rect.y = 0; 36 | rect.width = width; 37 | rect.height = height; 38 | 39 | this.freeRects.push(rect); 40 | } 41 | /** 42 | * 在线算法入口 插入矩形方法 43 | * @param width {number} 44 | * @param height {number} 45 | * @param method {FindPosition} 46 | */ 47 | public insert(width: number, height: number, method: FindPosition): Rect { 48 | // width height 参数合法性检查 49 | if (width <= 0 || height <= 0) { 50 | throw new Error( 51 | `width & height should greater than 0, but got width as ${width}, height as ${height}`, 52 | ); 53 | } 54 | // method 合法性检查 55 | if (method <= FindPosition.ShortSideFit || method >= FindPosition.AreaFit) { 56 | method = FindPosition.ShortSideFit; 57 | } 58 | 59 | let newRect = new Rect(); 60 | 61 | const score1: IScoreCounter = { 62 | value: 0, 63 | }; 64 | 65 | const score2: IScoreCounter = { 66 | value: 0, 67 | }; 68 | 69 | switch (method) { 70 | case FindPosition.ShortSideFit: 71 | newRect = this.findPositionForNewNodeBestShortSideFit( 72 | width, 73 | height, 74 | score1, 75 | score2, 76 | ); 77 | break; 78 | case FindPosition.BottomLeft: 79 | newRect = this.findPositionForNewNodeBottomLeft( 80 | width, 81 | height, 82 | score1, 83 | score2, 84 | ); 85 | break; 86 | case FindPosition.ContactPoint: 87 | newRect = this.findPositionForNewNodeContactPoint( 88 | width, 89 | height, 90 | score1, 91 | ); 92 | break; 93 | case FindPosition.LongSideFit: 94 | newRect = this.findPositionForNewNodeBestLongSideFit( 95 | width, 96 | height, 97 | score2, 98 | score1, 99 | ); 100 | break; 101 | case FindPosition.AreaFit: 102 | newRect = this.findPositionForNewNodeBestAreaFit( 103 | width, 104 | height, 105 | score1, 106 | score2, 107 | ); 108 | break; 109 | } 110 | 111 | if (newRect.height === 0) { 112 | return newRect; 113 | } 114 | if (this.allowRotate) { // 更新旋转属性 115 | if (newRect.height === height && newRect.width === width) { 116 | newRect.isRotated = false; 117 | } else { 118 | // TODO: check is really rotated 119 | newRect.isRotated = true; 120 | } 121 | } 122 | this.placeRectangle(newRect); 123 | return newRect; 124 | } 125 | /** 126 | * 算法离线入口 插入一组举行 127 | * @param rects {Rect[]} 矩形数组 128 | * @param method {FindPosition} 查找位置的方法 129 | */ 130 | public insertRects(rects: Rect[], method: FindPosition): Rect[] { 131 | // rects 参数合法性检查 132 | if (rects && rects.length === 0) { 133 | throw new Error('rects should be array with length greater than zero'); 134 | } 135 | // method 合法性检查 136 | if (method <= FindPosition.ShortSideFit || method >= FindPosition.AreaFit) { 137 | method = FindPosition.ShortSideFit; 138 | } 139 | 140 | const result: Rect[] = []; 141 | while (rects.length > 0) { 142 | const bestScore1: IScoreCounter = { 143 | value: Infinity, 144 | }; 145 | const bestScore2: IScoreCounter = { 146 | value: Infinity, 147 | }; 148 | let bestRectIndex = -1; 149 | let bestNode: Rect; 150 | 151 | for (let i = 0; i < rects.length; ++i) { 152 | const score1: IScoreCounter = { 153 | value: 0, 154 | }; 155 | const score2: IScoreCounter = { 156 | value: 0, 157 | }; 158 | const newNode: Rect = this.scoreRectangle( 159 | rects[i].width, 160 | rects[i].height, 161 | method, 162 | score1, 163 | score2, 164 | ); 165 | 166 | if ( 167 | score1.value < bestScore1.value || 168 | (score1.value === bestScore1.value && score2.value < bestScore2.value) 169 | ) { 170 | bestScore1.value = score1.value; 171 | bestScore2.value = score2.value; 172 | bestNode = newNode; 173 | bestRectIndex = i; 174 | } 175 | } 176 | 177 | if (bestRectIndex === -1) { 178 | return result; 179 | } 180 | this.placeRectangle(bestNode); 181 | 182 | bestNode.info = rects[bestRectIndex].info; 183 | if (this.allowRotate) { 184 | if ( 185 | bestNode.height === rects[bestRectIndex].height && 186 | bestNode.width === rects[bestRectIndex].width 187 | ) { 188 | bestNode.isRotated = false; 189 | } else { 190 | bestNode.isRotated = true; 191 | } 192 | } 193 | 194 | rects.splice(bestRectIndex, 1); 195 | 196 | result.push(bestNode); 197 | } 198 | return result; 199 | } 200 | public occupancy(): number { 201 | let usedSurfaceArea = 0; 202 | for (const rect of this.usedRects) { 203 | usedSurfaceArea += rect.width * rect.height; 204 | } 205 | return usedSurfaceArea / (this.containerWidth * this.containerHeight); 206 | } 207 | /** 208 | * 209 | * @param node 210 | */ 211 | private placeRectangle(node: Rect) { 212 | let numRectanglesToProcess = this.freeRects.length; 213 | for (let i = 0; i < numRectanglesToProcess; i++) { 214 | if (this.splitFreeNode(this.freeRects[i], node)) { 215 | this.freeRects.splice(i, 1); 216 | i--; 217 | numRectanglesToProcess--; 218 | } 219 | } 220 | 221 | this.pruneFreeList(); 222 | this.usedRects.push(node); 223 | } 224 | private scoreRectangle( 225 | width: number, 226 | height: number, 227 | method: FindPosition, 228 | score1: IScoreCounter, 229 | score2: IScoreCounter, 230 | ): Rect { 231 | let newNode = new Rect(); 232 | score1.value = Infinity; 233 | score2.value = Infinity; 234 | switch (method) { 235 | case FindPosition.ShortSideFit: 236 | newNode = this.findPositionForNewNodeBestShortSideFit( 237 | width, 238 | height, 239 | score1, 240 | score2, 241 | ); 242 | break; 243 | case FindPosition.BottomLeft: 244 | newNode = this.findPositionForNewNodeBottomLeft( 245 | width, 246 | height, 247 | score1, 248 | score2, 249 | ); 250 | break; 251 | case FindPosition.ContactPoint: 252 | newNode = this.findPositionForNewNodeContactPoint( 253 | width, 254 | height, 255 | score1, 256 | ); 257 | // todo: reverse 258 | score1.value = -score1.value; // Reverse since we are minimizing, but for contact point score bigger is better. 259 | break; 260 | case FindPosition.LongSideFit: 261 | newNode = this.findPositionForNewNodeBestLongSideFit( 262 | width, 263 | height, 264 | score2, 265 | score1, 266 | ); 267 | break; 268 | case FindPosition.AreaFit: 269 | newNode = this.findPositionForNewNodeBestAreaFit( 270 | width, 271 | height, 272 | score1, 273 | score2, 274 | ); 275 | break; 276 | } 277 | 278 | // Cannot fit the current Rectangle. 279 | if (newNode.height === 0) { 280 | score1.value = Infinity; 281 | score2.value = Infinity; 282 | } 283 | 284 | return newNode; 285 | } 286 | private findPositionForNewNodeBottomLeft( 287 | width: number, 288 | height: number, 289 | bestY: IScoreCounter, 290 | bestX: IScoreCounter, 291 | ): Rect { 292 | const freeRects = this.freeRects; 293 | const bestNode = new Rect(); 294 | 295 | bestY.value = Infinity; 296 | let topSideY; 297 | for (const rect of this.freeRects) { 298 | // Try to place the Rectangle in upright (non-flipped) orientation. 299 | if (rect.width >= width && rect.height >= height) { 300 | topSideY = rect.y + height; 301 | if ( 302 | topSideY < bestY.value || 303 | (topSideY === bestY.value && rect.x < bestX.value) 304 | ) { 305 | bestNode.x = rect.x; 306 | bestNode.y = rect.y; 307 | bestNode.width = width; 308 | bestNode.height = height; 309 | bestY.value = topSideY; 310 | bestX.value = rect.x; 311 | } 312 | } 313 | if (this.allowRotate && rect.width >= height && rect.height >= width) { 314 | topSideY = rect.y + width; 315 | if ( 316 | topSideY < bestY.value || 317 | (topSideY === bestY.value && rect.x < bestX.value) 318 | ) { 319 | bestNode.x = rect.x; 320 | bestNode.y = rect.y; 321 | bestNode.width = height; 322 | bestNode.height = width; 323 | bestY.value = topSideY; 324 | bestX.value = rect.x; 325 | } 326 | } 327 | } 328 | return bestNode; 329 | } 330 | private findPositionForNewNodeBestShortSideFit( 331 | width: number, 332 | height: number, 333 | bestShortSideFit: IScoreCounter, 334 | bestLongSideFit: IScoreCounter, 335 | ): Rect { 336 | const bestNode = new Rect(); 337 | bestShortSideFit.value = Infinity; 338 | 339 | let leftoverHoriz; 340 | let leftoverVert; 341 | let shortSideFit; 342 | let longSideFit; 343 | 344 | for (const rect of this.freeRects) { 345 | // Try to place the Rectangle in upright (non-flipped) orientation. 346 | if (rect.width >= width && rect.height >= height) { 347 | leftoverHoriz = Math.abs(rect.width - width); 348 | leftoverVert = Math.abs(rect.height - height); 349 | shortSideFit = Math.min(leftoverHoriz, leftoverVert); 350 | longSideFit = Math.max(leftoverHoriz, leftoverVert); 351 | 352 | if ( 353 | shortSideFit < bestShortSideFit.value || 354 | (shortSideFit === bestShortSideFit.value && 355 | longSideFit < bestLongSideFit.value) 356 | ) { 357 | bestNode.x = rect.x; 358 | bestNode.y = rect.y; 359 | bestNode.width = width; 360 | bestNode.height = height; 361 | bestShortSideFit.value = shortSideFit; 362 | bestLongSideFit.value = longSideFit; 363 | } 364 | } 365 | let flippedLeftoverHoriz; 366 | let flippedLeftoverVert; 367 | let flippedShortSideFit; 368 | let flippedLongSideFit; 369 | if (this.allowRotate && rect.width >= height && rect.height >= width) { 370 | flippedLeftoverHoriz = Math.abs(rect.width - height); 371 | flippedLeftoverVert = Math.abs(rect.height - width); 372 | flippedShortSideFit = Math.min( 373 | flippedLeftoverHoriz, 374 | flippedLeftoverVert, 375 | ); 376 | flippedLongSideFit = Math.max( 377 | flippedLeftoverHoriz, 378 | flippedLeftoverVert, 379 | ); 380 | 381 | if ( 382 | flippedShortSideFit < bestShortSideFit.value || 383 | (flippedShortSideFit === bestShortSideFit.value && 384 | flippedLongSideFit < bestLongSideFit.value) 385 | ) { 386 | bestNode.x = rect.x; 387 | bestNode.y = rect.y; 388 | bestNode.width = height; 389 | bestNode.height = width; 390 | bestShortSideFit.value = flippedShortSideFit; 391 | bestLongSideFit.value = flippedLongSideFit; 392 | } 393 | } 394 | } 395 | 396 | return bestNode; 397 | } 398 | private findPositionForNewNodeBestLongSideFit( 399 | width: number, 400 | height: number, 401 | bestShortSideFit: IScoreCounter, 402 | bestLongSideFit: IScoreCounter, 403 | ): Rect { 404 | const bestNode = new Rect(); 405 | bestLongSideFit.value = Infinity; 406 | 407 | let leftoverHoriz; 408 | let leftoverVert; 409 | let shortSideFit; 410 | let longSideFit; 411 | for (const rect of this.freeRects) { 412 | // Try to place the Rectangle in upright (non-flipped) orientation. 413 | if (rect.width >= width && rect.height >= height) { 414 | leftoverHoriz = Math.abs(rect.width - width); 415 | leftoverVert = Math.abs(rect.height - height); 416 | shortSideFit = Math.min(leftoverHoriz, leftoverVert); 417 | longSideFit = Math.max(leftoverHoriz, leftoverVert); 418 | 419 | if ( 420 | longSideFit < bestLongSideFit.value || 421 | (longSideFit === bestLongSideFit.value && 422 | shortSideFit < bestShortSideFit.value) 423 | ) { 424 | bestNode.x = rect.x; 425 | bestNode.y = rect.y; 426 | bestNode.width = width; 427 | bestNode.height = height; 428 | bestShortSideFit.value = shortSideFit; 429 | bestLongSideFit.value = longSideFit; 430 | } 431 | } 432 | 433 | if (this.allowRotate && rect.width >= height && rect.height >= width) { 434 | leftoverHoriz = Math.abs(rect.width - height); 435 | leftoverVert = Math.abs(rect.height - width); 436 | shortSideFit = Math.min(leftoverHoriz, leftoverVert); 437 | longSideFit = Math.max(leftoverHoriz, leftoverVert); 438 | 439 | if ( 440 | longSideFit < bestLongSideFit.value || 441 | (longSideFit === bestLongSideFit.value && 442 | shortSideFit < bestShortSideFit.value) 443 | ) { 444 | bestNode.x = rect.x; 445 | bestNode.y = rect.y; 446 | bestNode.width = height; 447 | bestNode.height = width; 448 | bestShortSideFit.value = shortSideFit; 449 | bestLongSideFit.value = longSideFit; 450 | } 451 | } 452 | } 453 | return bestNode; 454 | } 455 | private findPositionForNewNodeBestAreaFit( 456 | width: number, 457 | height: number, 458 | bestAreaFit: IScoreCounter, 459 | bestShortSideFit: IScoreCounter, 460 | ): Rect { 461 | const bestNode = new Rect(); 462 | bestAreaFit.value = Infinity; 463 | 464 | let leftoverHoriz; 465 | let leftoverVert; 466 | let shortSideFit; 467 | let areaFit; 468 | 469 | for (const rect of this.freeRects) { 470 | areaFit = rect.width * rect.height - width * height; 471 | 472 | // Try to place the Rectangle in upright (non-flipped) orientation. 473 | if (rect.width >= width && rect.height >= height) { 474 | leftoverHoriz = Math.abs(rect.width - width); 475 | leftoverVert = Math.abs(rect.height - height); 476 | shortSideFit = Math.min(leftoverHoriz, leftoverVert); 477 | 478 | if ( 479 | areaFit < bestAreaFit.value || 480 | (areaFit === bestAreaFit.value && 481 | shortSideFit < bestShortSideFit.value) 482 | ) { 483 | bestNode.x = rect.x; 484 | bestNode.y = rect.y; 485 | bestNode.width = width; 486 | bestNode.height = height; 487 | bestShortSideFit.value = shortSideFit; 488 | bestAreaFit.value = areaFit; 489 | } 490 | } 491 | 492 | if (this.allowRotate && rect.width >= height && rect.height >= width) { 493 | leftoverHoriz = Math.abs(rect.width - height); 494 | leftoverVert = Math.abs(rect.height - width); 495 | shortSideFit = Math.min(leftoverHoriz, leftoverVert); 496 | 497 | if ( 498 | areaFit < bestAreaFit.value || 499 | (areaFit === bestAreaFit.value && 500 | shortSideFit < bestShortSideFit.value) 501 | ) { 502 | bestNode.x = rect.x; 503 | bestNode.y = rect.y; 504 | bestNode.width = height; 505 | bestNode.height = width; 506 | bestShortSideFit.value = shortSideFit; 507 | bestAreaFit.value = areaFit; 508 | } 509 | } 510 | } 511 | return bestNode; 512 | } 513 | private commonIntervalLength( 514 | i1start: number, 515 | i1end: number, 516 | i2start: number, 517 | i2end: number, 518 | ): number { 519 | if (i1end < i2start || i2end < i1start) { 520 | return 0; 521 | } 522 | return Math.min(i1end, i2end) - Math.max(i1start, i2start); 523 | } 524 | private contactPointScoreNode( 525 | x: number, 526 | y: number, 527 | width: number, 528 | height: number, 529 | ): number { 530 | let score = 0; 531 | if (x === 0 || x + width === this.containerWidth) { 532 | score += height; 533 | } 534 | if (y === 0 || y + height === this.containerHeight) { 535 | score += width; 536 | } 537 | for (const rect of this.usedRects) { 538 | if (rect.x === x + width || rect.x + rect.width === x) { 539 | score += this.commonIntervalLength( 540 | rect.y, 541 | rect.y + rect.height, 542 | y, 543 | y + height, 544 | ); 545 | } 546 | if (rect.y === y + height || rect.y + rect.height === y) { 547 | score += this.commonIntervalLength( 548 | rect.x, 549 | rect.x + rect.width, 550 | x, 551 | x + width, 552 | ); 553 | } 554 | } 555 | return score; 556 | } 557 | private findPositionForNewNodeContactPoint( 558 | width: number, 559 | height: number, 560 | bestContactScore: IScoreCounter, 561 | ): Rect { 562 | const bestNode = new Rect(); 563 | bestContactScore.value = -1; 564 | 565 | let score; 566 | for (const rect of this.freeRects) { 567 | // Try to place the Rectangle in upright (non-flipped) orientation. 568 | if (rect.width >= width && rect.height >= height) { 569 | score = this.contactPointScoreNode(rect.x, rect.y, width, height); 570 | if (score > bestContactScore.value) { 571 | bestNode.x = rect.x; 572 | bestNode.y = rect.y; 573 | bestNode.width = width; 574 | bestNode.height = height; 575 | bestContactScore.value = score; 576 | } 577 | } 578 | if (this.allowRotate && rect.width >= height && rect.height >= width) { 579 | score = this.contactPointScoreNode(rect.x, rect.y, height, width); 580 | if (score > bestContactScore.value) { 581 | bestNode.x = rect.x; 582 | bestNode.y = rect.y; 583 | bestNode.width = height; 584 | bestNode.height = width; 585 | bestContactScore.value = score; 586 | } 587 | } 588 | } 589 | return bestNode; 590 | } 591 | private splitFreeNode(freeNode: Rect, usedNode: Rect): boolean { 592 | const freeRectangles = this.freeRects; 593 | // Test with SAT if the Rectangles even intersect. 594 | if ( 595 | usedNode.x >= freeNode.x + freeNode.width || 596 | usedNode.x + usedNode.width <= freeNode.x || 597 | usedNode.y >= freeNode.y + freeNode.height || 598 | usedNode.y + usedNode.height <= freeNode.y 599 | ) { 600 | return false; 601 | } 602 | let newNode; 603 | if ( 604 | usedNode.x < freeNode.x + freeNode.width && 605 | usedNode.x + usedNode.width > freeNode.x 606 | ) { 607 | // New node at the top side of the used node. 608 | if ( 609 | usedNode.y > freeNode.y && 610 | usedNode.y < freeNode.y + freeNode.height 611 | ) { 612 | newNode = freeNode.clone(); 613 | newNode.height = usedNode.y - newNode.y; 614 | freeRectangles.push(newNode); 615 | } 616 | 617 | // New node at the bottom side of the used node. 618 | if (usedNode.y + usedNode.height < freeNode.y + freeNode.height) { 619 | newNode = freeNode.clone(); 620 | newNode.y = usedNode.y + usedNode.height; 621 | newNode.height = 622 | freeNode.y + freeNode.height - (usedNode.y + usedNode.height); 623 | freeRectangles.push(newNode); 624 | } 625 | } 626 | 627 | if ( 628 | usedNode.y < freeNode.y + freeNode.height && 629 | usedNode.y + usedNode.height > freeNode.y 630 | ) { 631 | // New node at the left side of the used node. 632 | if (usedNode.x > freeNode.x && usedNode.x < freeNode.x + freeNode.width) { 633 | newNode = freeNode.clone(); 634 | newNode.width = usedNode.x - newNode.x; 635 | freeRectangles.push(newNode); 636 | } 637 | 638 | // New node at the right side of the used node. 639 | if (usedNode.x + usedNode.width < freeNode.x + freeNode.width) { 640 | newNode = freeNode.clone(); 641 | newNode.x = usedNode.x + usedNode.width; 642 | newNode.width = 643 | freeNode.x + freeNode.width - (usedNode.x + usedNode.width); 644 | freeRectangles.push(newNode); 645 | } 646 | } 647 | return true; 648 | } 649 | private pruneFreeList() { 650 | const freeRectangles = this.freeRects; 651 | for (let i = 0; i < freeRectangles.length; i++) { 652 | for (let j = i + 1; j < freeRectangles.length; j++) { 653 | if (freeRectangles[i].isIn(freeRectangles[j])) { 654 | freeRectangles.splice(i, 1); 655 | break; 656 | } 657 | if (freeRectangles[j].isIn(freeRectangles[i])) { 658 | freeRectangles.splice(j, 1); 659 | } 660 | } 661 | } 662 | } 663 | } 664 | 665 | export default MaxRectBinPack; 666 | --------------------------------------------------------------------------------